device-oauth-security.test.mjs
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