device-oauth-harness.mjs
170 lines 5.1 KB
Raw
sha256:c2dbf04d56308f3bbf2d06e6d2eb022b8948b1e827195fe525a44e5e18d5f9c0 feat(auth): Phase B Connect cloud agent (RFC 8628) + Hermes… Human minor ⚠ breaking 9 days ago
1 /**
2 * Shared helpers for Phase B device OAuth (RFC 8628) tests.
3 *
4 * Reuses the strong-consistency gateway refresh store from durable MCP OAuth
5 * harness and mounts createDeviceOAuthRouter on a throwaway Express server for
6 * HTTP integration / e2e / security tiers.
7 */
8
9 import express from 'express';
10 import http from 'node:http';
11 import {
12 createDeviceOAuthRouter,
13 DEVICE_GRANT_TYPE,
14 } from '../../hub/gateway/device-oauth-provider.mjs';
15 import {
16 createTempStrongStore,
17 TEST_SECRET,
18 } from './durable-mcp-oauth-harness.mjs';
19
20 export { TEST_SECRET, createTempStrongStore, DEVICE_GRANT_TYPE };
21
22 /**
23 * Mock Hub session: Bearer present → fixed test subject.
24 * @param {import('express').Request} req
25 * @returns {string | null}
26 */
27 export function defaultGetUserId(req) {
28 const auth = String(req.headers.authorization || '');
29 if (auth.startsWith('Bearer ')) return 'google:device-test';
30 return null;
31 }
32
33 /**
34 * Default granted scope ceiling for device-test subject.
35 * @param {string} _sub
36 * @returns {string[]}
37 */
38 export function defaultGrantedScopes(_sub) {
39 return ['vault:read', 'vault:write'];
40 }
41
42 /**
43 * Build a device OAuth router with test defaults.
44 *
45 * @param {{
46 * refreshStore: object,
47 * getUserId?: (req: import('express').Request) => string | null,
48 * grantedScopes?: (sub: string) => string[],
49 * sessionSecret?: string,
50 * baseUrl?: string,
51 * hubVerificationPath?: string,
52 * }} opts
53 */
54 export function createDeviceRouter(opts) {
55 if (!opts?.refreshStore) {
56 throw new Error('createDeviceRouter requires refreshStore');
57 }
58 return createDeviceOAuthRouter({
59 baseUrl: opts.baseUrl || 'http://localhost:3340',
60 sessionSecret: opts.sessionSecret || TEST_SECRET,
61 refreshStore: opts.refreshStore,
62 getUserId: opts.getUserId || defaultGetUserId,
63 grantedScopes: opts.grantedScopes || defaultGrantedScopes,
64 hubVerificationPath: opts.hubVerificationPath || '/hub/#settings/integrations',
65 });
66 }
67
68 /**
69 * Start an Express app with the device OAuth router on a random local port.
70 *
71 * @param {object} [opts]
72 * @param {ReturnType<typeof createTempStrongStore> extends Promise<infer T> ? T : never} [opts.tmp]
73 * @param {object} [opts.refreshStore]
74 * @param {string} [opts.mountPath]
75 * @param {(req: import('express').Request) => string | null} [opts.getUserId]
76 * @param {(sub: string) => string[]} [opts.grantedScopes]
77 * @param {string} [opts.sessionSecret]
78 * @returns {Promise<{
79 * baseUrl: string,
80 * mountPath: string,
81 * tmp: { dir: string, store: object, cleanup: () => Promise<void> },
82 * stop: () => Promise<void>,
83 * fetch: (method: string, urlPath: string, body?: object, headers?: Record<string, string>) => Promise<{
84 * status: number,
85 * headers: Headers,
86 * json: object | null,
87 * text: string,
88 * }>,
89 * }>}
90 */
91 export async function startDeviceOAuthApp(opts = {}) {
92 const tmp = opts.tmp || (await createTempStrongStore());
93 const mountPath = opts.mountPath || '/api/v1/auth/device';
94 const baseUrlForRouter = opts.baseUrl || 'http://localhost:3340';
95 const { router } = createDeviceRouter({
96 refreshStore: opts.refreshStore || tmp.store,
97 getUserId: opts.getUserId,
98 grantedScopes: opts.grantedScopes,
99 sessionSecret: opts.sessionSecret,
100 baseUrl: baseUrlForRouter,
101 hubVerificationPath: opts.hubVerificationPath,
102 });
103
104 const app = express();
105 app.set('trust proxy', true);
106 app.use(mountPath, router);
107
108 const server = http.createServer(app);
109 const baseUrl = await new Promise((resolve) => {
110 server.listen(0, '127.0.0.1', () => {
111 resolve(`http://127.0.0.1:${server.address().port}`);
112 });
113 });
114
115 return {
116 baseUrl,
117 mountPath,
118 tmp,
119 async stop() {
120 await new Promise((resolve, reject) => {
121 server.close((e) => {
122 if (e && e.code === 'ERR_SERVER_NOT_RUNNING') resolve();
123 else if (e) reject(e);
124 else resolve();
125 });
126 });
127 },
128 /**
129 * @param {string} method
130 * @param {string} urlPath
131 * @param {object} [body]
132 * @param {Record<string, string>} [headers]
133 */
134 async fetch(method, urlPath, body, headers = {}) {
135 const url = new URL(urlPath, baseUrl);
136 const contentType = headers['Content-Type'] || (body ? 'application/json' : undefined);
137 const bodyStr = body
138 ? contentType === 'application/json'
139 ? JSON.stringify(body)
140 : new URLSearchParams(
141 Object.fromEntries(Object.entries(body).map(([k, v]) => [k, String(v)]))
142 ).toString()
143 : undefined;
144 const res = await fetch(url.toString(), {
145 method,
146 headers: {
147 ...(contentType ? { 'Content-Type': contentType } : {}),
148 ...headers,
149 },
150 body: bodyStr,
151 });
152 const text = await res.text();
153 let json = null;
154 try {
155 json = JSON.parse(text);
156 } catch (_) {
157 /* non-json */
158 }
159 return { status: res.status, headers: res.headers, json, text };
160 },
161 };
162 }
163
164 /**
165 * Standard Authorization header for Hub-authenticated routes in tests.
166 * @returns {Record<string, string>}
167 */
168 export function authHeaders() {
169 return { Authorization: 'Bearer test-session-jwt' };
170 }
File History 1 commit
sha256:c2dbf04d56308f3bbf2d06e6d2eb022b8948b1e827195fe525a44e5e18d5f9c0 feat(auth): Phase B Connect cloud agent (RFC 8628) + Hermes… Human minor 9 days ago