device-oauth-security.test.mjs
150 lines 4.9 KB
Raw
sha256:c2dbf04d56308f3bbf2d06e6d2eb022b8948b1e827195fe525a44e5e18d5f9c0 feat(auth): Phase B Connect cloud agent (RFC 8628) + Hermes… Human minor ⚠ breaking 10 days ago
1 /**
2 * Phase B — device OAuth: security tier.
3 *
4 * Auth gates, brute-force rate limits, poll slow_down, and invariant that error
5 * bodies and durable store files never leak device_code or refresh secrets.
6 */
7
8 import { describe, it, afterEach } from 'node:test';
9 import assert from 'node:assert/strict';
10 import fs from 'node:fs/promises';
11 import path from 'node:path';
12 import {
13 startDeviceOAuthApp,
14 authHeaders,
15 DEVICE_GRANT_TYPE,
16 } from './helpers/device-oauth-harness.mjs';
17
18 const cleanups = [];
19 afterEach(async () => {
20 while (cleanups.length) await cleanups.pop()();
21 });
22
23 describe('Phase B security — auth and rate limits', () => {
24 it('approve without auth returns 401', async () => {
25 const app = await startDeviceOAuthApp();
26 cleanups.push(async () => {
27 await app.stop();
28 await app.tmp.cleanup();
29 });
30
31 const authRes = await app.fetch('POST', `${app.mountPath}/authorize`, {});
32 const res = await app.fetch('POST', `${app.mountPath}/approve`, {
33 user_code: authRes.json.user_code,
34 });
35 assert.equal(res.status, 401);
36 assert.equal(res.json.error, 'unauthorized');
37 });
38
39 it('many wrong user_codes on approve eventually returns 429', async () => {
40 const app = await startDeviceOAuthApp();
41 cleanups.push(async () => {
42 await app.stop();
43 await app.tmp.cleanup();
44 });
45
46 let saw429 = false;
47 const bruteIp = { 'X-Forwarded-For': '10.99.0.1' };
48 for (let i = 0; i < 25; i++) {
49 const res = await app.fetch(
50 'POST',
51 `${app.mountPath}/approve`,
52 { user_code: `ZZZZ-${String(i).padStart(4, '0')}` },
53 { ...authHeaders(), ...bruteIp }
54 );
55 if (res.status === 429) {
56 saw429 = true;
57 assert.equal(res.json.error, 'rate_limited');
58 break;
59 }
60 assert.ok([404, 400].includes(res.status), `unexpected status ${res.status}`);
61 }
62 assert.ok(saw429, 'approve brute-force must rate-limit after APPROVE_MAX_ATTEMPTS');
63 });
64
65 it('poll too fast returns slow_down', async () => {
66 const app = await startDeviceOAuthApp();
67 cleanups.push(async () => {
68 await app.stop();
69 await app.tmp.cleanup();
70 });
71
72 const authRes = await app.fetch('POST', `${app.mountPath}/authorize`, {});
73 const deviceCode = authRes.json.device_code;
74
75 const first = await app.fetch('POST', `${app.mountPath}/token`, {
76 grant_type: DEVICE_GRANT_TYPE,
77 device_code: deviceCode,
78 });
79 assert.equal(first.status, 400);
80 assert.equal(first.json.error, 'authorization_pending');
81
82 const fast = await app.fetch('POST', `${app.mountPath}/token`, {
83 grant_type: DEVICE_GRANT_TYPE,
84 device_code: deviceCode,
85 });
86 assert.equal(fast.status, 400);
87 assert.equal(fast.json.error, 'slow_down');
88 });
89 });
90
91 describe('Phase B security — no secrets in errors or store', () => {
92 it('error bodies never contain device_code or refresh secrets from store', async () => {
93 const app = await startDeviceOAuthApp();
94 cleanups.push(async () => {
95 await app.stop();
96 await app.tmp.cleanup();
97 });
98
99 const authRes = await app.fetch('POST', `${app.mountPath}/authorize`, {});
100 const deviceCode = authRes.json.device_code;
101 const approveRes = await app.fetch(
102 'POST',
103 `${app.mountPath}/approve`,
104 { user_code: authRes.json.user_code },
105 authHeaders()
106 );
107 assert.equal(approveRes.status, 200);
108
109 const tokenRes = await app.fetch('POST', `${app.mountPath}/token`, {
110 grant_type: DEVICE_GRANT_TYPE,
111 device_code: deviceCode,
112 });
113 assert.equal(tokenRes.status, 200);
114 assert.ok(tokenRes.json.refresh_token);
115 const refreshSecret = tokenRes.json.refresh_token.split('.')[1];
116
117 const badPoll = await app.fetch('POST', `${app.mountPath}/token`, {
118 grant_type: DEVICE_GRANT_TYPE,
119 device_code: 'totally-wrong-device-code',
120 });
121 assert.ok(!badPoll.text.includes(deviceCode));
122 assert.ok(!badPoll.text.includes(refreshSecret));
123
124 const badRefresh = await app.fetch('POST', `${app.mountPath}/token`, {
125 grant_type: 'refresh_token',
126 refresh_token: 'not-a-real-refresh',
127 });
128 assert.ok(!badRefresh.text.includes(refreshSecret));
129 assert.ok(!badRefresh.text.includes(deviceCode));
130 });
131
132 it('raw device_code never written to device_pending_codes.json', async () => {
133 const app = await startDeviceOAuthApp();
134 cleanups.push(async () => {
135 await app.stop();
136 await app.tmp.cleanup();
137 });
138
139 const authRes = await app.fetch('POST', `${app.mountPath}/authorize`, {});
140 const raw = await fs.readFile(path.join(app.tmp.dir, 'device_pending_codes.json'), 'utf8');
141 assert.ok(!raw.includes(authRes.json.device_code), 'store must not contain raw device_code');
142 const refreshRaw = await fs.readFile(
143 path.join(app.tmp.dir, 'hosted_refresh_tokens.json'),
144 'utf8'
145 ).catch(() => '{}');
146 if (authRes.json.refresh_token) {
147 assert.ok(!refreshRaw.includes(authRes.json.refresh_token));
148 }
149 });
150 });
File History 1 commit
sha256:c2dbf04d56308f3bbf2d06e6d2eb022b8948b1e827195fe525a44e5e18d5f9c0 feat(auth): Phase B Connect cloud agent (RFC 8628) + Hermes… Human minor 10 days ago