companion-oauth-flow.mjs
237 lines 8.0 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Companion App — Phase 5 native OAuth flow driver.
3 *
4 * Binds a one-shot loopback redirect listener, opens the system browser, validates
5 * `state` and `iss`, exchanges the code over TLS, and stores the resulting session
6 * through injected keychain custody.
7 */
8
9 import http from 'node:http';
10 import { spawn as nodeSpawn } from 'node:child_process';
11
12 import {
13 buildAuthorizationUrl,
14 buildTokenRequest,
15 createNonce,
16 createOAuthState,
17 createPkcePair,
18 validateAuthorizationResponse,
19 validateTokenResponse,
20 } from './companion-oauth-pkce.mjs';
21 import { buildSessionMeta, createTokenCustody } from './companion-token-custody.mjs';
22
23 export const OAUTH_FLOW_REASONS = Object.freeze({
24 BIND_FAILED: 'bind_failed',
25 CALLBACK_INVALID: 'callback_invalid',
26 CALLBACK_TIMEOUT: 'callback_timeout',
27 TOKEN_EXCHANGE_FAILED: 'token_exchange_failed',
28 REGISTRATION_FAILED: 'registration_failed',
29 });
30
31 /**
32 * Open the default system browser without an embedded webview.
33 * @param {string} url
34 * @param {{ platform?: string, spawn?: typeof nodeSpawn }} [deps]
35 */
36 export async function openSystemBrowser(url, deps = {}) {
37 const platform = deps.platform ?? process.platform;
38 const spawn = deps.spawn ?? nodeSpawn;
39 const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
40 const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
41 const child = spawn(command, args, { shell: false, detached: true, stdio: 'ignore' });
42 child.unref?.();
43 }
44
45 /**
46 * Bind a one-shot loopback OAuth redirect listener.
47 * @param {{ expectedState: string, expectedIssuer: string, path?: string, host?: '127.0.0.1'|'::1', timeoutMs?: number, createServer?: typeof http.createServer }} params
48 */
49 export async function createOAuthRedirectAttempt(params) {
50 const {
51 expectedState,
52 expectedIssuer,
53 path = '/callback',
54 host = '127.0.0.1',
55 timeoutMs = 120_000,
56 createServer = http.createServer,
57 } = params ?? {};
58 if (host !== '127.0.0.1' && host !== '::1') throw new TypeError(OAUTH_FLOW_REASONS.BIND_FAILED);
59
60 let settled = false;
61 let timeout;
62 let resolveCallback;
63 const callback = new Promise((resolve, reject) => {
64 resolveCallback = { resolve, reject };
65 timeout = setTimeout(() => reject(new Error(OAUTH_FLOW_REASONS.CALLBACK_TIMEOUT)), timeoutMs);
66 });
67
68 const server = createServer((req, res) => {
69 if (settled) {
70 res.statusCode = 410;
71 res.end('Authorization attempt is closed.');
72 return;
73 }
74 settled = true;
75 try {
76 if (req.method !== 'GET') throw new Error(OAUTH_FLOW_REASONS.CALLBACK_INVALID);
77 const requestUrl = new URL(req.url ?? '/', `http://${host}`);
78 if (requestUrl.pathname !== path) throw new Error(OAUTH_FLOW_REASONS.CALLBACK_INVALID);
79 const paramsObj = Object.fromEntries(requestUrl.searchParams.entries());
80 const verdict = validateAuthorizationResponse({
81 params: paramsObj,
82 expectedState,
83 expectedIssuer,
84 });
85 if (!verdict.ok) throw new Error(OAUTH_FLOW_REASONS.CALLBACK_INVALID);
86 res.statusCode = 200;
87 res.setHeader('Content-Type', 'text/plain; charset=utf-8');
88 res.setHeader('Cache-Control', 'no-store');
89 res.end('Sign-in complete. You can return to Knowtation.');
90 resolveCallback.resolve(verdict.code);
91 } catch {
92 res.statusCode = 400;
93 res.setHeader('Content-Type', 'text/plain; charset=utf-8');
94 res.setHeader('Cache-Control', 'no-store');
95 res.end('Sign-in failed. Return to Knowtation and try again.');
96 resolveCallback.reject(new Error(OAUTH_FLOW_REASONS.CALLBACK_INVALID));
97 } finally {
98 clearTimeout(timeout);
99 server.close();
100 }
101 });
102
103 await new Promise((resolve, reject) => {
104 server.once('error', () => reject(new Error(OAUTH_FLOW_REASONS.BIND_FAILED)));
105 server.listen(0, host, () => {
106 server.off('error', reject);
107 resolve();
108 });
109 });
110 const address = server.address();
111 if (!address || typeof address !== 'object') {
112 server.close();
113 throw new Error(OAUTH_FLOW_REASONS.BIND_FAILED);
114 }
115 const redirectHost = address.address === '::1' ? '[::1]' : '127.0.0.1';
116 return {
117 redirectUri: `http://${redirectHost}:${address.port}${path}`,
118 callback,
119 close() {
120 clearTimeout(timeout);
121 if (server.listening) server.close();
122 },
123 };
124 }
125
126 /**
127 * Register a public native client when a registration endpoint is supplied.
128 * @param {{ registrationEndpoint?: string, redirectUri: string, fetch: typeof globalThis.fetch }} params
129 */
130 export async function registerNativeClient({ registrationEndpoint, redirectUri, fetch }) {
131 if (!registrationEndpoint) return null;
132 const endpoint = new URL(registrationEndpoint);
133 if (endpoint.protocol !== 'https:') throw new Error(OAUTH_FLOW_REASONS.REGISTRATION_FAILED);
134 const res = await fetch(endpoint, {
135 method: 'POST',
136 headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
137 body: JSON.stringify({
138 redirect_uris: [redirectUri],
139 token_endpoint_auth_method: 'none',
140 grant_types: ['authorization_code', 'refresh_token'],
141 response_types: ['code'],
142 }),
143 });
144 if (!res.ok) throw new Error(OAUTH_FLOW_REASONS.REGISTRATION_FAILED);
145 const json = await res.json();
146 if (!json || typeof json.client_id !== 'string' || json.client_id.length === 0) {
147 throw new Error(OAUTH_FLOW_REASONS.REGISTRATION_FAILED);
148 }
149 return json.client_id;
150 }
151
152 /**
153 * Run the full native OAuth flow.
154 * @param {{
155 * authorizationEndpoint: string,
156 * tokenEndpoint: string,
157 * expectedIssuer: string,
158 * clientId?: string,
159 * registrationEndpoint?: string,
160 * scopes: string[],
161 * keychain: { get: Function, set: Function, delete: Function },
162 * fetch?: typeof globalThis.fetch,
163 * openBrowser?: (url: string) => Promise<void>|void,
164 * now?: () => number,
165 * refreshTtlMs?: number,
166 * }} params
167 */
168 export async function runCompanionOAuthFlow(params) {
169 const fetch = params?.fetch ?? globalThis.fetch;
170 const openBrowser = params?.openBrowser ?? ((url) => openSystemBrowser(url));
171 if (typeof fetch !== 'function') throw new TypeError(OAUTH_FLOW_REASONS.TOKEN_EXCHANGE_FAILED);
172
173 const pkce = createPkcePair();
174 const state = createOAuthState();
175 const nonce = createNonce();
176 const attempt = await createOAuthRedirectAttempt({
177 expectedState: state,
178 expectedIssuer: params.expectedIssuer,
179 });
180
181 try {
182 const clientId = params.clientId ?? await registerNativeClient({
183 registrationEndpoint: params.registrationEndpoint,
184 redirectUri: attempt.redirectUri,
185 fetch,
186 });
187 if (typeof clientId !== 'string' || clientId.length === 0) {
188 throw new Error(OAUTH_FLOW_REASONS.REGISTRATION_FAILED);
189 }
190
191 const authorizationUrl = buildAuthorizationUrl({
192 authorizationEndpoint: params.authorizationEndpoint,
193 clientId,
194 redirectUri: attempt.redirectUri,
195 scopes: params.scopes,
196 state,
197 codeChallenge: pkce.codeChallenge,
198 nonce,
199 });
200
201 await openBrowser(authorizationUrl);
202 const code = await attempt.callback;
203 const tokenRequest = buildTokenRequest({
204 tokenEndpoint: params.tokenEndpoint,
205 clientId,
206 code,
207 codeVerifier: pkce.codeVerifier,
208 redirectUri: attempt.redirectUri,
209 });
210
211 const tokenRes = await fetch(tokenRequest.url, {
212 method: tokenRequest.method,
213 headers: tokenRequest.headers,
214 body: tokenRequest.body,
215 });
216 if (!tokenRes.ok) throw new Error(OAUTH_FLOW_REASONS.TOKEN_EXCHANGE_FAILED);
217 const tokenJson = await tokenRes.json();
218 const token = validateTokenResponse(tokenJson);
219 if (!token.ok) throw new Error(OAUTH_FLOW_REASONS.TOKEN_EXCHANGE_FAILED);
220
221 const meta = buildSessionMeta(token, {
222 now: params.now?.() ?? Date.now(),
223 refreshTtlMs: params.refreshTtlMs,
224 issuer: params.expectedIssuer,
225 });
226 const custody = createTokenCustody(params.keychain);
227 await custody.storeSession({
228 accessToken: token.accessToken,
229 refreshToken: token.refreshToken,
230 meta,
231 });
232 return { ok: true, meta };
233 } catch (err) {
234 attempt.close();
235 throw err;
236 }
237 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago