device-oauth-unit.test.mjs
146 lines 5.6 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 (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