device-oauth-integration.test.mjs
sha256:c2dbf04d56308f3bbf2d06e6d2eb022b8948b1e827195fe525a44e5e18d5f9c0
feat(auth): Phase B Connect cloud agent (RFC 8628) + Hermes…
Human
minor
⚠ breaking
11 days ago
| 1 | /** |
| 2 | * Phase B — device OAuth: integration tier. |
| 3 | * |
| 4 | * HTTP authorize → approve → token poll; refresh rotation; setup-pack non-secrets; |
| 5 | * durability across a new router instance on the same data directory. |
| 6 | */ |
| 7 | |
| 8 | import { describe, it, afterEach } from 'node:test'; |
| 9 | import assert from 'node:assert/strict'; |
| 10 | import jwt from 'jsonwebtoken'; |
| 11 | import { createGatewayRefreshStore } from '../hub/gateway/refresh-token-store.mjs'; |
| 12 | import { |
| 13 | startDeviceOAuthApp, |
| 14 | authHeaders, |
| 15 | TEST_SECRET, |
| 16 | DEVICE_GRANT_TYPE, |
| 17 | createTempStrongStore, |
| 18 | } from './helpers/device-oauth-harness.mjs'; |
| 19 | |
| 20 | const cleanups = []; |
| 21 | afterEach(async () => { |
| 22 | while (cleanups.length) await cleanups.pop()(); |
| 23 | }); |
| 24 | |
| 25 | describe('Phase B integration — HTTP device flow', () => { |
| 26 | it('authorize → approve → poll /token returns mcp_access + refresh', async () => { |
| 27 | const app = await startDeviceOAuthApp(); |
| 28 | cleanups.push(async () => { |
| 29 | await app.stop(); |
| 30 | await app.tmp.cleanup(); |
| 31 | }); |
| 32 | |
| 33 | const authRes = await app.fetch('POST', `${app.mountPath}/authorize`, { |
| 34 | client_id: 'hermes-cloud', |
| 35 | client_name: 'Hermes Agent', |
| 36 | scope: 'vault:read vault:write', |
| 37 | }); |
| 38 | assert.equal(authRes.status, 200); |
| 39 | assert.ok(authRes.json.device_code); |
| 40 | assert.ok(authRes.json.user_code); |
| 41 | |
| 42 | const approveRes = await app.fetch( |
| 43 | 'POST', |
| 44 | `${app.mountPath}/approve`, |
| 45 | { user_code: authRes.json.user_code }, |
| 46 | authHeaders() |
| 47 | ); |
| 48 | assert.equal(approveRes.status, 200); |
| 49 | assert.equal(approveRes.json.ok, true); |
| 50 | |
| 51 | const tokenRes = await app.fetch('POST', `${app.mountPath}/token`, { |
| 52 | grant_type: DEVICE_GRANT_TYPE, |
| 53 | device_code: authRes.json.device_code, |
| 54 | }); |
| 55 | assert.equal(tokenRes.status, 200); |
| 56 | assert.ok(tokenRes.json.access_token); |
| 57 | assert.ok(tokenRes.json.refresh_token); |
| 58 | assert.equal(tokenRes.json.token_type, 'bearer'); |
| 59 | |
| 60 | const payload = jwt.verify(tokenRes.json.access_token, TEST_SECRET); |
| 61 | assert.equal(payload.type, 'mcp_access'); |
| 62 | assert.equal(payload.sub, 'google:device-test'); |
| 63 | assert.ok(payload.scopes.includes('vault:read')); |
| 64 | }); |
| 65 | |
| 66 | it('refresh_token grant rotates refresh secret', async () => { |
| 67 | const app = await startDeviceOAuthApp(); |
| 68 | cleanups.push(async () => { |
| 69 | await app.stop(); |
| 70 | await app.tmp.cleanup(); |
| 71 | }); |
| 72 | |
| 73 | const authRes = await app.fetch('POST', `${app.mountPath}/authorize`, {}); |
| 74 | await app.fetch( |
| 75 | 'POST', |
| 76 | `${app.mountPath}/approve`, |
| 77 | { user_code: authRes.json.user_code }, |
| 78 | authHeaders() |
| 79 | ); |
| 80 | const first = await app.fetch('POST', `${app.mountPath}/token`, { |
| 81 | grant_type: DEVICE_GRANT_TYPE, |
| 82 | device_code: authRes.json.device_code, |
| 83 | }); |
| 84 | const refresh1 = first.json.refresh_token; |
| 85 | |
| 86 | const rotated = await app.fetch('POST', `${app.mountPath}/token`, { |
| 87 | grant_type: 'refresh_token', |
| 88 | refresh_token: refresh1, |
| 89 | }); |
| 90 | assert.equal(rotated.status, 200); |
| 91 | assert.ok(rotated.json.access_token); |
| 92 | assert.ok(rotated.json.refresh_token); |
| 93 | assert.notEqual(rotated.json.refresh_token, refresh1); |
| 94 | }); |
| 95 | |
| 96 | it('setup-pack returns secrets:false and no token fields', async () => { |
| 97 | const app = await startDeviceOAuthApp(); |
| 98 | cleanups.push(async () => { |
| 99 | await app.stop(); |
| 100 | await app.tmp.cleanup(); |
| 101 | }); |
| 102 | |
| 103 | const res = await app.fetch('GET', `${app.mountPath}/setup-pack`); |
| 104 | assert.equal(res.status, 200); |
| 105 | assert.equal(res.json.secrets, false); |
| 106 | assert.ok(!('access_token' in res.json)); |
| 107 | assert.ok(!('refresh_token' in res.json)); |
| 108 | assert.ok(!('device_code' in res.json)); |
| 109 | assert.equal(res.json.phase_b?.status, 'available'); |
| 110 | }); |
| 111 | |
| 112 | it('restart durability: pending code survives new router on same store dir', async () => { |
| 113 | const tmp = await createTempStrongStore(); |
| 114 | cleanups.push(tmp.cleanup); |
| 115 | |
| 116 | const app1 = await startDeviceOAuthApp({ tmp }); |
| 117 | cleanups.push(async () => app1.stop()); |
| 118 | const authRes = await app1.fetch('POST', `${app1.mountPath}/authorize`, { |
| 119 | client_name: 'Restart Test', |
| 120 | }); |
| 121 | |
| 122 | const store2 = createGatewayRefreshStore({ consistency: 'strong' }); |
| 123 | const app2 = await startDeviceOAuthApp({ tmp, refreshStore: store2 }); |
| 124 | cleanups.push(async () => app2.stop()); |
| 125 | await app1.stop(); |
| 126 | |
| 127 | const approveRes = await app2.fetch( |
| 128 | 'POST', |
| 129 | `${app2.mountPath}/approve`, |
| 130 | { user_code: authRes.json.user_code }, |
| 131 | authHeaders() |
| 132 | ); |
| 133 | assert.equal(approveRes.status, 200); |
| 134 | |
| 135 | const tokenRes = await app2.fetch('POST', `${app2.mountPath}/token`, { |
| 136 | grant_type: DEVICE_GRANT_TYPE, |
| 137 | device_code: authRes.json.device_code, |
| 138 | }); |
| 139 | assert.equal(tokenRes.status, 200); |
| 140 | assert.ok(tokenRes.json.access_token); |
| 141 | }); |
| 142 | }); |
File History
1 commit
sha256:93bcf8f9bd56d8c5b9339f4ec73b9ebd66571398d56262d38eedc2cfa9db9882
fix(test): align Band B landing assertion with desktop MCP …
Human
11 days ago