companion-oauth-flow.mjs
261 lines 8.9 KB
Raw
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ breaking 25 days 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 const MAX_OAUTH_RESPONSE_BYTES = 64 * 1024;
31
32 async function parseBoundedJson(response, failureReason) {
33 let text;
34 try {
35 text = await response.text();
36 if (Buffer.byteLength(text) > MAX_OAUTH_RESPONSE_BYTES) throw new Error('oversized');
37 return JSON.parse(text);
38 } catch {
39 throw new Error(failureReason);
40 }
41 }
42
43 /**
44 * Open the default system browser without an embedded webview.
45 * @param {string} url
46 * @param {{ platform?: string, spawn?: typeof nodeSpawn }} [deps]
47 */
48 export async function openSystemBrowser(url, deps = {}) {
49 const platform = deps.platform ?? process.platform;
50 const spawn = deps.spawn ?? nodeSpawn;
51 const command = platform === 'darwin' ? '/usr/bin/open' : platform === 'win32' ? 'cmd' : 'xdg-open';
52 const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
53 const child = spawn(command, args, { shell: false, detached: true, stdio: 'ignore' });
54 child.unref?.();
55 }
56
57 /**
58 * Bind a one-shot loopback OAuth redirect listener.
59 * @param {{ expectedState: string, expectedIssuer: string, path?: string, host?: '127.0.0.1'|'::1', timeoutMs?: number, createServer?: typeof http.createServer }} params
60 */
61 export async function createOAuthRedirectAttempt(params) {
62 const {
63 expectedState,
64 expectedIssuer,
65 path = '/callback',
66 host = '127.0.0.1',
67 timeoutMs = 120_000,
68 createServer = http.createServer,
69 } = params ?? {};
70 if (host !== '127.0.0.1' && host !== '::1') throw new TypeError(OAUTH_FLOW_REASONS.BIND_FAILED);
71
72 let settled = false;
73 let timeout;
74 let resolveCallback;
75 const callback = new Promise((resolve, reject) => {
76 resolveCallback = { resolve, reject };
77 timeout = setTimeout(() => reject(new Error(OAUTH_FLOW_REASONS.CALLBACK_TIMEOUT)), timeoutMs);
78 });
79
80 const server = createServer((req, res) => {
81 if (settled) {
82 res.statusCode = 410;
83 res.end('Authorization attempt is closed.');
84 return;
85 }
86 settled = true;
87 try {
88 if (req.method !== 'GET') throw new Error(OAUTH_FLOW_REASONS.CALLBACK_INVALID);
89 const requestUrl = new URL(req.url ?? '/', `http://${host}`);
90 if (requestUrl.pathname !== path) throw new Error(OAUTH_FLOW_REASONS.CALLBACK_INVALID);
91 const paramsObj = Object.fromEntries(requestUrl.searchParams.entries());
92 const verdict = validateAuthorizationResponse({
93 params: paramsObj,
94 expectedState,
95 expectedIssuer,
96 });
97 if (!verdict.ok) throw new Error(OAUTH_FLOW_REASONS.CALLBACK_INVALID);
98 res.statusCode = 200;
99 res.setHeader('Content-Type', 'text/plain; charset=utf-8');
100 res.setHeader('Cache-Control', 'no-store');
101 res.end('Sign-in complete. You can return to Knowtation.');
102 resolveCallback.resolve(verdict.code);
103 } catch {
104 res.statusCode = 400;
105 res.setHeader('Content-Type', 'text/plain; charset=utf-8');
106 res.setHeader('Cache-Control', 'no-store');
107 res.end('Sign-in failed. Return to Knowtation and try again.');
108 resolveCallback.reject(new Error(OAUTH_FLOW_REASONS.CALLBACK_INVALID));
109 } finally {
110 clearTimeout(timeout);
111 server.close();
112 }
113 });
114
115 try {
116 await new Promise((resolve, reject) => {
117 const onError = () => reject(new Error(OAUTH_FLOW_REASONS.BIND_FAILED));
118 server.once('error', onError);
119 server.listen(0, host, () => {
120 server.off('error', onError);
121 resolve();
122 });
123 });
124 } catch {
125 settled = true;
126 clearTimeout(timeout);
127 if (server.listening) server.close();
128 // The callback promise is not returned when binding fails. Resolve it so a failed bind
129 // cannot leave an unhandled promise or a live timeout behind.
130 resolveCallback.resolve(null);
131 throw new Error(OAUTH_FLOW_REASONS.BIND_FAILED);
132 }
133 const address = server.address();
134 if (!address || typeof address !== 'object') {
135 server.close();
136 throw new Error(OAUTH_FLOW_REASONS.BIND_FAILED);
137 }
138 const redirectHost = address.address === '::1' ? '[::1]' : '127.0.0.1';
139 return {
140 redirectUri: `http://${redirectHost}:${address.port}${path}`,
141 callback,
142 close() {
143 clearTimeout(timeout);
144 if (server.listening) server.close();
145 },
146 };
147 }
148
149 /**
150 * Register a public native client when a registration endpoint is supplied.
151 * @param {{ registrationEndpoint?: string, redirectUri: string, fetch: typeof globalThis.fetch }} params
152 */
153 export async function registerNativeClient({ registrationEndpoint, redirectUri, fetch }) {
154 if (!registrationEndpoint) return null;
155 const endpoint = new URL(registrationEndpoint);
156 if (endpoint.protocol !== 'https:') throw new Error(OAUTH_FLOW_REASONS.REGISTRATION_FAILED);
157 const res = await fetch(endpoint, {
158 method: 'POST',
159 headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
160 body: JSON.stringify({
161 redirect_uris: [redirectUri],
162 token_endpoint_auth_method: 'none',
163 grant_types: ['authorization_code', 'refresh_token'],
164 response_types: ['code'],
165 }),
166 });
167 if (!res.ok) throw new Error(OAUTH_FLOW_REASONS.REGISTRATION_FAILED);
168 const json = await parseBoundedJson(res, OAUTH_FLOW_REASONS.REGISTRATION_FAILED);
169 if (!json || typeof json.client_id !== 'string' || json.client_id.length === 0) {
170 throw new Error(OAUTH_FLOW_REASONS.REGISTRATION_FAILED);
171 }
172 return json.client_id;
173 }
174
175 /**
176 * Run the full native OAuth flow.
177 * @param {{
178 * authorizationEndpoint: string,
179 * tokenEndpoint: string,
180 * expectedIssuer: string,
181 * clientId?: string,
182 * registrationEndpoint?: string,
183 * scopes: string[],
184 * keychain: { get: Function, set: Function, delete: Function },
185 * fetch?: typeof globalThis.fetch,
186 * openBrowser?: (url: string) => Promise<void>|void,
187 * now?: () => number,
188 * refreshTtlMs?: number,
189 * }} params
190 */
191 export async function runCompanionOAuthFlow(params) {
192 const fetch = params?.fetch ?? globalThis.fetch;
193 const openBrowser = params?.openBrowser ?? ((url) => openSystemBrowser(url));
194 if (typeof fetch !== 'function') throw new TypeError(OAUTH_FLOW_REASONS.TOKEN_EXCHANGE_FAILED);
195
196 const pkce = createPkcePair();
197 const state = createOAuthState();
198 const nonce = createNonce();
199 const attempt = await createOAuthRedirectAttempt({
200 expectedState: state,
201 expectedIssuer: params.expectedIssuer,
202 });
203
204 try {
205 const clientId = params.clientId ?? await registerNativeClient({
206 registrationEndpoint: params.registrationEndpoint,
207 redirectUri: attempt.redirectUri,
208 fetch,
209 });
210 if (typeof clientId !== 'string' || clientId.length === 0) {
211 throw new Error(OAUTH_FLOW_REASONS.REGISTRATION_FAILED);
212 }
213
214 const authorizationUrl = buildAuthorizationUrl({
215 authorizationEndpoint: params.authorizationEndpoint,
216 clientId,
217 redirectUri: attempt.redirectUri,
218 scopes: params.scopes,
219 state,
220 codeChallenge: pkce.codeChallenge,
221 nonce,
222 });
223
224 await openBrowser(authorizationUrl);
225 const code = await attempt.callback;
226 const tokenRequest = buildTokenRequest({
227 tokenEndpoint: params.tokenEndpoint,
228 clientId,
229 code,
230 codeVerifier: pkce.codeVerifier,
231 redirectUri: attempt.redirectUri,
232 });
233
234 const tokenRes = await fetch(tokenRequest.url, {
235 method: tokenRequest.method,
236 headers: tokenRequest.headers,
237 body: tokenRequest.body,
238 });
239 if (!tokenRes.ok) throw new Error(OAUTH_FLOW_REASONS.TOKEN_EXCHANGE_FAILED);
240 const tokenJson = await parseBoundedJson(tokenRes, OAUTH_FLOW_REASONS.TOKEN_EXCHANGE_FAILED);
241 const token = validateTokenResponse(tokenJson);
242 if (!token.ok) throw new Error(OAUTH_FLOW_REASONS.TOKEN_EXCHANGE_FAILED);
243
244 const meta = buildSessionMeta(token, {
245 now: params.now?.() ?? Date.now(),
246 refreshTtlMs: params.refreshTtlMs,
247 issuer: params.expectedIssuer,
248 clientId,
249 });
250 const custody = createTokenCustody(params.keychain);
251 await custody.storeSession({
252 accessToken: token.accessToken,
253 refreshToken: token.refreshToken,
254 meta,
255 });
256 return { ok: true, meta };
257 } catch (err) {
258 attempt.close();
259 throw err;
260 }
261 }
File History 1 commit
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ 25 days ago