device-oauth-unit.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 (RFC 8628): unit tier. |
| 3 | * |
| 4 | * Store primitives (user/device code generation, normalization, hash-at-rest), |
| 5 | * approve/deny/poll state machine, scope ceiling helper, TTL constants, and |
| 6 | * single-use consumption after approval. |
| 7 | */ |
| 8 | |
| 9 | import { describe, it, afterEach } from 'node:test'; |
| 10 | import assert from 'node:assert/strict'; |
| 11 | import fs from 'node:fs/promises'; |
| 12 | import path from 'node:path'; |
| 13 | import { |
| 14 | generateUserCode, |
| 15 | normalizeUserCode, |
| 16 | hashDeviceCode, |
| 17 | createDeviceAuthorization, |
| 18 | approveUserCode, |
| 19 | denyUserCode, |
| 20 | pollDeviceCode, |
| 21 | DEVICE_CODE_TTL_MS, |
| 22 | DEVICE_POLL_INTERVAL_SEC, |
| 23 | } from '../hub/gateway/device-oauth-store.mjs'; |
| 24 | import { |
| 25 | applyDeviceScopeCeiling, |
| 26 | DEVICE_CODE_TTL_MS as PROVIDER_TTL_MS, |
| 27 | MCP_TOKEN_EXPIRY_SECONDS, |
| 28 | } from '../hub/gateway/device-oauth-provider.mjs'; |
| 29 | import { createTempStrongStore } from './helpers/device-oauth-harness.mjs'; |
| 30 | |
| 31 | const cleanups = []; |
| 32 | afterEach(async () => { |
| 33 | while (cleanups.length) await cleanups.pop()(); |
| 34 | }); |
| 35 | |
| 36 | describe('Phase B unit — code helpers', () => { |
| 37 | it('generateUserCode produces XXXX-XXXX with safe alphabet', () => { |
| 38 | const code = generateUserCode(); |
| 39 | assert.match(code, /^[A-Z2-9]{4}-[A-Z2-9]{4}$/); |
| 40 | assert.ok(!code.includes('O')); |
| 41 | assert.ok(!code.includes('0')); |
| 42 | assert.ok(!code.includes('I')); |
| 43 | assert.ok(!code.includes('1')); |
| 44 | }); |
| 45 | |
| 46 | it('normalizeUserCode strips spaces/hyphens and uppercases', () => { |
| 47 | assert.equal(normalizeUserCode('abcd-efgh'), 'ABCD-EFGH'); |
| 48 | assert.equal(normalizeUserCode(' abcd efgh '), 'ABCD-EFGH'); |
| 49 | assert.equal(normalizeUserCode('ABCD EFGH'), 'ABCD-EFGH'); |
| 50 | }); |
| 51 | |
| 52 | it('hashDeviceCode is deterministic sha256 hex', () => { |
| 53 | const h1 = hashDeviceCode('secret-device-code'); |
| 54 | const h2 = hashDeviceCode('secret-device-code'); |
| 55 | assert.equal(h1, h2); |
| 56 | assert.match(h1, /^[a-f0-9]{64}$/); |
| 57 | assert.notEqual(h1, hashDeviceCode('other')); |
| 58 | }); |
| 59 | }); |
| 60 | |
| 61 | describe('Phase B unit — TTL constants', () => { |
| 62 | it('device code TTL is 15 minutes and poll interval defaults to 5s', () => { |
| 63 | assert.equal(DEVICE_CODE_TTL_MS, 15 * 60 * 1000); |
| 64 | assert.equal(DEVICE_POLL_INTERVAL_SEC, 5); |
| 65 | assert.equal(PROVIDER_TTL_MS, DEVICE_CODE_TTL_MS); |
| 66 | assert.equal(MCP_TOKEN_EXPIRY_SECONDS, 3600); |
| 67 | }); |
| 68 | }); |
| 69 | |
| 70 | describe('Phase B unit — applyDeviceScopeCeiling', () => { |
| 71 | it('filters requested scopes to ceiling', () => { |
| 72 | assert.deepEqual( |
| 73 | applyDeviceScopeCeiling(['vault:read', 'vault:write', 'admin'], ['vault:read', 'vault:write']), |
| 74 | ['vault:read', 'vault:write'] |
| 75 | ); |
| 76 | }); |
| 77 | |
| 78 | it('empty ceiling falls back to vault:read only', () => { |
| 79 | assert.deepEqual(applyDeviceScopeCeiling(['vault:write'], []), ['vault:read']); |
| 80 | }); |
| 81 | |
| 82 | it('empty requested returns full ceiling', () => { |
| 83 | assert.deepEqual(applyDeviceScopeCeiling([], ['vault:read', 'vault:write']), ['vault:read', 'vault:write']); |
| 84 | }); |
| 85 | }); |
| 86 | |
| 87 | describe('Phase B unit — store lifecycle', () => { |
| 88 | it('createDeviceAuthorization stores hash not raw device_code', async () => { |
| 89 | const tmp = await createTempStrongStore(); |
| 90 | cleanups.push(tmp.cleanup); |
| 91 | const created = await createDeviceAuthorization({ clientId: 'unit-test' }); |
| 92 | const raw = await fs.readFile(path.join(tmp.dir, 'device_pending_codes.json'), 'utf8'); |
| 93 | assert.ok(!raw.includes(created.deviceCode), 'raw device_code must not appear in store'); |
| 94 | assert.ok(raw.includes(hashDeviceCode(created.deviceCode)), 'store key must be hash'); |
| 95 | assert.ok(raw.includes(created.userCode), 'user_code is stored for Hub lookup'); |
| 96 | }); |
| 97 | |
| 98 | it('approveUserCode / denyUserCode / pollDeviceCode happy paths', async () => { |
| 99 | const tmp = await createTempStrongStore(); |
| 100 | cleanups.push(tmp.cleanup); |
| 101 | const { deviceCode, userCode } = await createDeviceAuthorization({ intervalSec: 1 }); |
| 102 | |
| 103 | const pending = await pollDeviceCode(deviceCode); |
| 104 | assert.equal(pending.status, 'authorization_pending'); |
| 105 | |
| 106 | const approved = await approveUserCode(userCode, 'google:device-test'); |
| 107 | assert.equal(approved.ok, true); |
| 108 | assert.equal(approved.entry.status, 'approved'); |
| 109 | assert.equal(approved.entry.userId, 'google:device-test'); |
| 110 | |
| 111 | const pollOk = await pollDeviceCode(deviceCode); |
| 112 | assert.equal(pollOk.status, 'approved'); |
| 113 | assert.equal(pollOk.entry.userId, 'google:device-test'); |
| 114 | }); |
| 115 | |
| 116 | it('denyUserCode yields access_denied on poll', async () => { |
| 117 | const tmp = await createTempStrongStore(); |
| 118 | cleanups.push(tmp.cleanup); |
| 119 | const { deviceCode, userCode } = await createDeviceAuthorization({ intervalSec: 1 }); |
| 120 | const denied = await denyUserCode(userCode, 'google:device-test'); |
| 121 | assert.equal(denied.ok, true); |
| 122 | const poll = await pollDeviceCode(deviceCode); |
| 123 | assert.equal(poll.status, 'access_denied'); |
| 124 | }); |
| 125 | |
| 126 | it('single-use: second poll after approve returns invalid_grant', async () => { |
| 127 | const tmp = await createTempStrongStore(); |
| 128 | cleanups.push(tmp.cleanup); |
| 129 | const { deviceCode, userCode } = await createDeviceAuthorization({ intervalSec: 1 }); |
| 130 | await approveUserCode(userCode, 'google:device-test'); |
| 131 | const first = await pollDeviceCode(deviceCode); |
| 132 | assert.equal(first.status, 'approved'); |
| 133 | const second = await pollDeviceCode(deviceCode); |
| 134 | assert.equal(second.status, 'invalid_grant'); |
| 135 | }); |
| 136 | |
| 137 | it('poll too fast within interval returns slow_down', async () => { |
| 138 | const tmp = await createTempStrongStore(); |
| 139 | cleanups.push(tmp.cleanup); |
| 140 | const { deviceCode } = await createDeviceAuthorization({ intervalSec: 60 }); |
| 141 | const first = await pollDeviceCode(deviceCode); |
| 142 | assert.equal(first.status, 'authorization_pending'); |
| 143 | const fast = await pollDeviceCode(deviceCode); |
| 144 | assert.equal(fast.status, 'slow_down'); |
| 145 | }); |
| 146 | }); |
File History
1 commit
sha256:c2dbf04d56308f3bbf2d06e6d2eb022b8948b1e827195fe525a44e5e18d5f9c0
feat(auth): Phase B Connect cloud agent (RFC 8628) + Hermes…
Human
minor
⚠
10 days ago