native-oauth-provider.mjs
641 lines 26.2 KB
Raw
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ breaking 25 days ago
1 /**
2 * Native client OAuth 2.1 provider for the Knowtation companion app.
3 *
4 * Implements changes C1–C6 from docs/COMPANION-APP-OAUTH-SERVERSIDE-GATE.md §6:
5 *
6 * C1 – Mints the web-session JWT (issueToken shape: {sub, provider, id, name, role})
7 * for native loopback clients. The `mcp_access` path is untouched.
8 * C2 – Refresh is backed by refresh-token-core (rotation + reuse→family-revoke).
9 * The new refresh token is delivered in the response body (not a cookie), since
10 * the companion is not a browser.
11 * C3 – Emits `iss` = issuerUrl on the loopback redirect (RFC 9207 §2 mix-up defense).
12 * Value is exactly the `issuer` advertised in the discovery metadata.
13 * C4 – Pending auth codes are stored in native-as-store.mjs (survive process restart).
14 * Refresh tokens use the same durable gateway refresh store as web sessions.
15 * C5 – Validates `redirect_uri` at token exchange per RFC 6749 §4.1.3: the value in
16 * the token request MUST exactly equal the one bound at authorization.
17 * C6 – Scope ceiling guard: never issues a superset of scopesForRole(role). Unknown
18 * or missing role → member ceiling ([vault:read, vault:write]), fail-closed.
19 *
20 * Endpoints (mounted by server.mjs at /api/v1/auth/native):
21 * GET /.well-known/oauth-authorization-server RFC 8414 discovery metadata
22 * POST /register RFC 7591 dynamic client registration
23 * GET /authorize RFC 6749 PKCE authorization start
24 * POST /token RFC 6749 code exchange + refresh
25 * POST /revoke RFC 7009 token revocation
26 *
27 * Security invariants enforced here:
28 * - Only loopback redirect URIs (127.0.0.1 or [::1]) accepted at registration and
29 * authorization (RFC 8252 §7.3, §8.3). Non-loopback → rejected, fail-closed.
30 * - PKCE S256 required at every authorization. `plain` method rejected.
31 * - redirect_uri equality check at code exchange (RFC 6749 §4.1.3).
32 * - Scope ceiling applied at code exchange AND on every refresh rotation (role may
33 * have changed since last login).
34 * - No secret (SESSION_SECRET, JWT, refresh token, code, verifier) appears in any
35 * log line, error body, or redirect URL.
36 * - mcp_access clients (Claude Desktop etc.) are completely unaffected by this module.
37 *
38 * D-SS.4 SDK verification (docs §4): @modelcontextprotocol/sdk ^1.27.1 accepts any
39 * loopback port at registration (no port filtering in OAuthClientMetadataSchema) and
40 * performs exact-match at /authorize against registered redirect_uris — consistent with
41 * RFC 8252 §7.3 ("allow any port") across registrations and exact-match within one flow.
42 * The companion binds one ephemeral port per attempt, derives its redirect_uri from it,
43 * and presents that same value at authorization AND token exchange, satisfying both.
44 */
45
46 import crypto from 'node:crypto';
47 import { createHash } from 'node:crypto';
48 import express from 'express';
49 import {
50 savePendingCode,
51 bindUserToCode,
52 consumePendingCode,
53 } from './native-as-store.mjs';
54
55 /** Access token lifetime. Shorter than the web session for defense-in-depth on native. */
56 const NATIVE_TOKEN_EXPIRY_SECONDS = 15 * 60; // 15 minutes
57
58 /** Refresh token per-token inactivity TTL. Aligns with DEFAULT_TOKEN_TTL_MS in refresh-token-core. */
59 const NATIVE_REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
60
61 /** Max registered clients before evicting the oldest. */
62 const MAX_NATIVE_CLIENTS = 200;
63
64 // ─── Helpers ─────────────────────────────────────────────────────────────────
65
66 /**
67 * Validate that `uri` is an RFC 8252 §7.3 loopback literal.
68 * Accepts http://127.0.0.1:<port>/... and http://[::1]:<port>/...
69 * Rejects: `localhost` hostname (DNS-resolvable, not a literal), non-HTTP schemes,
70 * non-loopback hosts, missing port, or any URI that cannot be parsed.
71 * Exported for tests.
72 *
73 * @param {string} uri
74 * @returns {boolean}
75 */
76 export function isLoopbackUri(uri) {
77 try {
78 const url = new URL(uri);
79 if (url.protocol !== 'http:') return false;
80 const h = url.hostname;
81 // Accept the IPv4 and IPv6 loopback literals only.
82 // RFC 8252 §8.3 explicitly prohibits the use of `localhost` because it can
83 // be hijacked via /etc/hosts or local DNS. We enforce the same restriction.
84 return h === '127.0.0.1' || h === '[::1]' || h === '::1';
85 } catch (_) {
86 return false;
87 }
88 }
89
90 /**
91 * Compute SHA-256(verifier) as base64url — the S256 PKCE challenge method.
92 * @param {string} verifier
93 * @returns {string}
94 */
95 function _sha256Base64url(verifier) {
96 return createHash('sha256').update(String(verifier)).digest('base64url');
97 }
98
99 /**
100 * Map a refresh-token-core failure reason to a stable error code + message.
101 * Aligned to the codes in hub/auth-session.mjs `refreshError` so callers and
102 * tests can use the same constants across both endpoints.
103 *
104 * @param {string} reason
105 * @returns {{ code: string, message: string }}
106 */
107 function _nativeRefreshError(reason) {
108 switch (reason) {
109 case 'reuse':
110 return { code: 'REFRESH_REUSE', message: 'Session was invalidated. Please sign in again.' };
111 case 'expired':
112 return { code: 'REFRESH_EXPIRED', message: 'Session expired. Please sign in again.' };
113 case 'revoked':
114 return { code: 'REFRESH_REVOKED', message: 'Session was revoked. Please sign in again.' };
115 default:
116 return { code: 'UNAUTHORIZED', message: 'Invalid session.' };
117 }
118 }
119
120 // ─── Scope ceiling (C6) ──────────────────────────────────────────────────────
121
122 /**
123 * Apply the scope ceiling for a native client grant (C6).
124 *
125 * Rules:
126 * - If `requested` is non-empty, return only the intersection with `ceiling`.
127 * - If `requested` is empty/absent, return the full ceiling.
128 * - The ceiling is always `scopesForRole(role)` and is never exceeded.
129 * - Unknown/missing role → member ceiling, enforced by the injected `grantedScopes`.
130 *
131 * Exported for unit tests.
132 *
133 * @param {string[]} requested - scopes requested by the client
134 * @param {string[]} ceiling - maximum scopes allowed (from scopesForRole)
135 * @returns {string[]}
136 */
137 export function applyScopeCeiling(requested, ceiling) {
138 if (!Array.isArray(requested) || requested.length === 0) return [...ceiling];
139 return requested.filter((s) => Array.isArray(ceiling) && ceiling.includes(s));
140 }
141
142 // ─── In-memory client store ───────────────────────────────────────────────────
143
144 /**
145 * In-memory store for registered native OAuth clients.
146 *
147 * Client registrations are session-scoped: the companion re-registers on each auth
148 * attempt and registrations need not survive process restart (C4 mandates durable
149 * state only for pending codes and refresh records). An in-memory store is therefore
150 * appropriate — it is also simpler and avoids a durable write on every registration.
151 *
152 * Security: only loopback redirect URIs are accepted. Any non-loopback URI in
153 * `redirect_uris` causes the entire registration to be rejected (fail-closed, C6
154 * spirit + RFC 8252 §8.3).
155 */
156 class NativeClientStore {
157 constructor() {
158 /** @type {Map<string, object>} */
159 this._clients = new Map();
160 }
161
162 /**
163 * @param {string} clientId
164 * @returns {object|undefined}
165 */
166 getClient(clientId) {
167 return this._clients.get(clientId);
168 }
169
170 /**
171 * Register a new native client after validating that every redirect_uri is a
172 * loopback literal.
173 *
174 * @param {{ redirect_uris?: string[], [key: string]: unknown }} meta
175 * @returns {object} full client record (includes generated client_id)
176 * @throws {Error} if redirect_uris is missing/empty or contains a non-loopback URI
177 */
178 registerClient(meta) {
179 const uris = Array.isArray(meta.redirect_uris) ? meta.redirect_uris : [];
180 if (uris.length === 0) {
181 const e = new Error('redirect_uris is required for native clients');
182 e.code = 'invalid_client_metadata';
183 throw e;
184 }
185 for (const uri of uris) {
186 if (!isLoopbackUri(uri)) {
187 const e = new Error(`redirect_uri must be a loopback literal (127.0.0.1 or [::1]): ${uri}`);
188 e.code = 'invalid_redirect_uri';
189 throw e;
190 }
191 }
192
193 // Evict the oldest registration when at capacity.
194 if (this._clients.size >= MAX_NATIVE_CLIENTS) {
195 let oldest = null;
196 let oldestTime = Infinity;
197 for (const [id, c] of this._clients) {
198 if (c.client_id_issued_at < oldestTime) {
199 oldest = id;
200 oldestTime = c.client_id_issued_at;
201 }
202 }
203 if (oldest) this._clients.delete(oldest);
204 }
205
206 const clientId = crypto.randomUUID();
207 const now = Math.floor(Date.now() / 1000);
208 const full = {
209 ...meta,
210 client_id: clientId,
211 client_id_issued_at: now,
212 // Native clients are always public (no secret); enforce this regardless of
213 // what the client requested.
214 token_endpoint_auth_method: 'none',
215 grant_types: ['authorization_code', 'refresh_token'],
216 response_types: ['code'],
217 };
218 this._clients.set(clientId, full);
219 return full;
220 }
221 }
222
223 // ─── Router factory ───────────────────────────────────────────────────────────
224
225 /**
226 * Create the native OAuth 2.1 Express router and the `completeNativeAuthorization`
227 * callback used by server.mjs after the IDP (Google/GitHub) redirects back.
228 *
229 * @param {{
230 * baseUrl: string,
231 * loginUrl?: string,
232 * issueAccessToken: (sub: string) => (string | Promise<string>),
233 * grantedScopes: (sub: string) => string[],
234 * refreshStore: {
235 * issue: (sub: string, opts?: object) => Promise<{ token: string, id: string, familyId: string }>,
236 * rotate: (token: string, opts?: object) => Promise<{ ok: boolean, token?: string, sub?: string, reason?: string }>,
237 * revoke: (token: string) => Promise<{ revoked: boolean, sub: string|null }>,
238 * peek: (token: string) => Promise<null | { sub: string, meta: object, revoked: boolean, consumed: boolean, expires_at: number, family_expires_at: number }>,
239 * },
240 * }} opts
241 * @returns {{
242 * router: import('express').Router,
243 * completeNativeAuthorization: (nativeStateBase64: string, userId: string, res: import('express').Response) => Promise<void>,
244 * }}
245 */
246 export function createNativeOAuthRouter(opts) {
247 const baseUrl = opts.baseUrl.replace(/\/$/, '');
248
249 /**
250 * The native AS issuer identifier. Used as `iss` in redirects (C3) and as the
251 * `issuer` field in discovery metadata. Must be stable and HTTPS on the gateway host.
252 * On localhost/dev the MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL env flag allows HTTP.
253 */
254 const issuerUrl = `${baseUrl}/api/v1/auth/native`;
255
256 const loginUrl = (opts.loginUrl || `${baseUrl}/auth/login`).replace(/\/$/, '');
257
258 const clientStore = new NativeClientStore();
259 const router = express.Router();
260
261 // ── Discovery (RFC 8414) ──────────────────────────────────────────────────
262
263 router.get('/.well-known/oauth-authorization-server', (_req, res) => {
264 res.set('Cache-Control', 'public, max-age=3600');
265 res.json({
266 issuer: issuerUrl,
267 authorization_endpoint: `${issuerUrl}/authorize`,
268 token_endpoint: `${issuerUrl}/token`,
269 registration_endpoint: `${issuerUrl}/register`,
270 revocation_endpoint: `${issuerUrl}/revoke`,
271 response_types_supported: ['code'],
272 grant_types_supported: ['authorization_code', 'refresh_token'],
273 code_challenge_methods_supported: ['S256'],
274 token_endpoint_auth_methods_supported: ['none'],
275 scopes_supported: ['vault:read', 'vault:write'],
276 });
277 });
278
279 // ── Dynamic client registration (RFC 7591) ────────────────────────────────
280
281 router.post('/register', express.json({ limit: '16kb' }), (req, res) => {
282 res.set('Cache-Control', 'no-store');
283 let client;
284 try {
285 client = clientStore.registerClient(req.body || {});
286 } catch (e) {
287 const errorCode = e.code || 'invalid_client_metadata';
288 // Do NOT reflect the URI back in the error — it may be a probe for injection.
289 return res.status(400).json({
290 error: errorCode,
291 error_description: 'redirect_uris must be loopback literals (http://127.0.0.1 or http://[::1])',
292 });
293 }
294 return res.status(201).json(client);
295 });
296
297 // ── Authorization endpoint (RFC 6749 §4.1.1 + RFC 7636) ─────────────────
298
299 router.get('/authorize', async (req, res) => {
300 res.set('Cache-Control', 'no-store');
301 const q = req.query;
302
303 // ─ Mandatory parameter validation ─
304 if (
305 !q.client_id ||
306 !q.redirect_uri ||
307 !q.code_challenge ||
308 q.code_challenge_method !== 'S256'
309 ) {
310 return res.status(400).json({
311 error: 'invalid_request',
312 error_description: 'client_id, redirect_uri, code_challenge, and code_challenge_method=S256 are required',
313 });
314 }
315
316 const client = clientStore.getClient(String(q.client_id));
317 if (!client) {
318 return res.status(400).json({ error: 'invalid_client', error_description: 'Unknown client_id' });
319 }
320
321 const redirectUriStr = String(q.redirect_uri);
322
323 // Exact-match against registered URIs (SDK behavior; port included in match).
324 if (!Array.isArray(client.redirect_uris) || !client.redirect_uris.includes(redirectUriStr)) {
325 return res.status(400).json({ error: 'invalid_request', error_description: 'Unregistered redirect_uri' });
326 }
327 // Double-check loopback requirement at authorization time (defense-in-depth: client
328 // store already enforced this at registration, but guard again here, fail-closed).
329 if (!isLoopbackUri(redirectUriStr)) {
330 return res.status(400).json({ error: 'invalid_request', error_description: 'redirect_uri must be a loopback literal' });
331 }
332
333 const requestedScopes = q.scope
334 ? String(q.scope).split(' ').filter(Boolean)
335 : [];
336
337 const code = crypto.randomUUID();
338
339 try {
340 await savePendingCode(code, {
341 clientId: String(q.client_id),
342 codeChallenge: String(q.code_challenge),
343 redirectUri: redirectUriStr,
344 state: q.state ? String(q.state) : null,
345 scopes: requestedScopes,
346 });
347 } catch (_) {
348 return res.status(503).json({
349 error: 'server_error',
350 error_description: 'Authorization service temporarily unavailable',
351 });
352 }
353
354 // Encode state for round-trip through the IDP (Google/GitHub).
355 // The `native_state` carries enough context for completeNativeAuthorization to
356 // find and bind the pending record without exposing the code challenge.
357 const nativeState = Buffer.from(
358 JSON.stringify({
359 code,
360 clientId: String(q.client_id),
361 redirectUri: redirectUriStr,
362 state: q.state ? String(q.state) : null,
363 })
364 ).toString('base64url');
365
366 const loginTarget = new URL(loginUrl);
367 loginTarget.searchParams.set('provider', 'google');
368 loginTarget.searchParams.set('native_state', nativeState);
369 return res.redirect(loginTarget.toString());
370 });
371
372 // ── Token endpoint (RFC 6749 §4.1.3 + §6) ────────────────────────────────
373
374 router.post(
375 '/token',
376 express.urlencoded({ extended: false }),
377 express.json({ limit: '16kb' }),
378 async (req, res) => {
379 res.set('Cache-Control', 'no-store');
380 const body = req.body || {};
381 const grantType = body.grant_type;
382
383 // Native clients are public, so client_id is an identifier rather than a secret.
384 // Authorization-code exchange still requires the live registration. Refresh binds
385 // client_id to the durable token record, allowing an installed app to refresh after
386 // a gateway restart without weakening that binding.
387 const clientId = typeof body.client_id === 'string' ? body.client_id : '';
388 if (!clientId) {
389 return res.status(401).json({
390 error: 'invalid_client',
391 error_description: 'Missing client_id',
392 });
393 }
394
395 // ─ authorization_code grant ──────────────────────────────────────────
396 if (grantType === 'authorization_code') {
397 const client = clientStore.getClient(clientId);
398 if (!client) {
399 return res.status(401).json({
400 error: 'invalid_client',
401 error_description: 'Unknown client_id',
402 });
403 }
404 const { code, code_verifier, redirect_uri } = body;
405
406 if (!code || !code_verifier || !redirect_uri) {
407 return res.status(400).json({
408 error: 'invalid_request',
409 error_description: 'code, code_verifier, and redirect_uri are required',
410 });
411 }
412
413 let pending;
414 try {
415 pending = await consumePendingCode(String(code));
416 } catch (_) {
417 return res.status(503).json({
418 error: 'server_error',
419 error_description: 'Authorization service temporarily unavailable',
420 });
421 }
422
423 if (!pending) {
424 return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization code invalid or expired' });
425 }
426 if (pending.clientId !== String(clientId)) {
427 return res.status(400).json({ error: 'invalid_grant', error_description: 'Client mismatch' });
428 }
429
430 // C5: redirect_uri MUST equal the one bound at authorization (RFC 6749 §4.1.3).
431 if (String(redirect_uri) !== pending.redirectUri) {
432 return res.status(400).json({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' });
433 }
434
435 // PKCE S256: sha256(code_verifier) must equal the stored challenge.
436 if (_sha256Base64url(String(code_verifier)) !== pending.codeChallenge) {
437 return res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' });
438 }
439
440 // Authorization must have been completed (userId bound by IDP callback).
441 if (!pending.userId) {
442 return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization not completed' });
443 }
444
445 const sub = pending.userId;
446
447 // C6: scope ceiling — intersection of requested and grantedScopes(sub).
448 // grantedScopes is scopesForRole(roleForSub(sub)); unknown role → member ceiling.
449 const ceiling = opts.grantedScopes(sub);
450 const effectiveScopes = applyScopeCeiling(pending.scopes, ceiling);
451
452 // C1: mint the web-session JWT (issueToken shape: {sub, provider, id, name, role}).
453 let accessToken;
454 try {
455 accessToken = await opts.issueAccessToken(sub);
456 } catch (_) {
457 return res.status(500).json({ error: 'server_error', error_description: 'Token issuance failed' });
458 }
459
460 // C2/C4: issue refresh token via durable gateway store (refresh-token-core backing).
461 let refreshResult;
462 try {
463 refreshResult = await opts.refreshStore.issue(sub, {
464 tokenTtlMs: NATIVE_REFRESH_TOKEN_TTL_MS,
465 meta: {
466 ua: String(req.headers['user-agent'] || '').slice(0, 256),
467 client_id: clientId,
468 scopes: effectiveScopes.join(' '),
469 },
470 });
471 } catch (_) {
472 return res.status(503).json({ error: 'server_error', error_description: 'Refresh token issuance failed' });
473 }
474
475 return res.status(200).json({
476 access_token: accessToken,
477 token_type: 'Bearer',
478 expires_in: NATIVE_TOKEN_EXPIRY_SECONDS,
479 refresh_token: refreshResult.token,
480 scope: effectiveScopes.join(' '),
481 });
482 }
483
484 // ─ refresh_token grant (C2) ──────────────────────────────────────────
485 if (grantType === 'refresh_token') {
486 const presentedToken = body.refresh_token;
487 if (!presentedToken) {
488 return res.status(400).json({ error: 'invalid_request', error_description: 'refresh_token is required' });
489 }
490
491 let peeked;
492 try {
493 peeked = await opts.refreshStore.peek(String(presentedToken));
494 } catch (_) {
495 return res.status(503).json({
496 error: 'server_error',
497 error_description: 'Session service temporarily unavailable',
498 code: 'SESSION_STORE_UNAVAILABLE',
499 });
500 }
501 if (!peeked || peeked.meta?.client_id !== clientId) {
502 return res.status(401).json({
503 error: 'invalid_grant',
504 error_description: 'Invalid session.',
505 code: 'UNAUTHORIZED',
506 });
507 }
508
509 let result;
510 try {
511 result = await opts.refreshStore.rotate(String(presentedToken), {
512 meta: {
513 ...peeked.meta,
514 ua: String(req.headers['user-agent'] || '').slice(0, 256),
515 },
516 });
517 } catch (_) {
518 // A transient store fault: do NOT treat as theft. Fail soft with 503.
519 return res.status(503).json({
520 error: 'server_error',
521 error_description: 'Session service temporarily unavailable',
522 code: 'SESSION_STORE_UNAVAILABLE',
523 });
524 }
525
526 if (!result.ok) {
527 const err = _nativeRefreshError(result.reason);
528 // C2: reason codes aligned to auth-session.mjs.
529 return res.status(401).json({ error: 'invalid_grant', error_description: err.message, code: err.code });
530 }
531
532 const sub = result.sub;
533 // C6: re-derive ceiling on every refresh (role may have changed since last login).
534 const ceiling = opts.grantedScopes(sub);
535 const storedScopes = typeof result.meta?.scopes === 'string' && result.meta.scopes.trim()
536 ? result.meta.scopes.trim().split(/\s+/).filter(Boolean)
537 : ceiling;
538 const effectiveScopes = applyScopeCeiling(storedScopes, ceiling);
539
540 let accessToken;
541 try {
542 accessToken = await opts.issueAccessToken(sub);
543 } catch (_) {
544 return res.status(500).json({ error: 'server_error', error_description: 'Token issuance failed' });
545 }
546
547 // C2: new refresh token in the response body (not a cookie).
548 return res.status(200).json({
549 access_token: accessToken,
550 token_type: 'Bearer',
551 expires_in: NATIVE_TOKEN_EXPIRY_SECONDS,
552 refresh_token: result.token,
553 scope: effectiveScopes.join(' '),
554 });
555 }
556
557 return res.status(400).json({
558 error: 'unsupported_grant_type',
559 error_description: 'grant_type must be authorization_code or refresh_token',
560 });
561 }
562 );
563
564 // ── Revocation (RFC 7009) ──────────────────────────────────────────────────
565
566 router.post(
567 '/revoke',
568 express.urlencoded({ extended: false }),
569 express.json({ limit: '16kb' }),
570 async (req, res) => {
571 res.set('Cache-Control', 'no-store');
572 const body = req.body || {};
573 const token = body.token;
574 if (token) {
575 try {
576 await opts.refreshStore.revoke(String(token));
577 } catch (_) {
578 // RFC 7009 §2.2: revocation always returns 200 regardless of errors.
579 }
580 }
581 return res.status(200).json({ ok: true });
582 }
583 );
584
585 // ─── completeNativeAuthorization ─────────────────────────────────────────
586
587 /**
588 * Complete the native authorization after the IDP (Google/GitHub) OAuth callback.
589 * Called from server.mjs when the round-trip state has the `native:` prefix.
590 *
591 * C3: Emits `iss` = issuerUrl on the loopback redirect (RFC 9207 §2).
592 * Value is identical to the `issuer` field in the discovery metadata, with no
593 * trailing-slash drift.
594 *
595 * C5: The redirect target is the `redirectUri` stored at authorization time (not
596 * taken from the current request), so a forged/mismatched redirect cannot be
597 * injected at this step.
598 *
599 * @param {string} nativeStateBase64 - base64url-encoded JSON from the `native:` state
600 * @param {string} userId - authenticated user sub ("provider:id") from passport
601 * @param {import('express').Response} res
602 * @returns {Promise<void>}
603 */
604 async function completeNativeAuthorization(nativeStateBase64, userId, res) {
605 let nativeState;
606 try {
607 nativeState = JSON.parse(Buffer.from(String(nativeStateBase64), 'base64url').toString('utf8'));
608 } catch (_) {
609 return res.status(400).json({ error: 'invalid_request', error_description: 'Malformed native state' });
610 }
611
612 const { code, redirectUri } = nativeState;
613 if (!code || !redirectUri) {
614 return res.status(400).json({ error: 'invalid_request', error_description: 'Malformed native state: missing code or redirectUri' });
615 }
616
617 // Bind the authenticated user to the pending code in the durable store (C4).
618 let bound;
619 try {
620 bound = await bindUserToCode(String(code), String(userId));
621 } catch (_) {
622 return res.status(503).json({ error: 'server_error', error_description: 'Authorization service temporarily unavailable' });
623 }
624
625 if (!bound) {
626 return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization code invalid or expired' });
627 }
628
629 // Build the loopback redirect. The target is the stored redirectUri, not the
630 // current request URI, so the companion's listener receives the code at its port.
631 const redirectUrl = new URL(String(redirectUri));
632 redirectUrl.searchParams.set('code', String(code));
633 if (nativeState.state) redirectUrl.searchParams.set('state', String(nativeState.state));
634 // C3: iss parameter — equal to the issuerUrl advertised in discovery (RFC 9207 §2).
635 redirectUrl.searchParams.set('iss', issuerUrl);
636
637 return res.redirect(redirectUrl.toString());
638 }
639
640 return { router, completeNativeAuthorization };
641 }
File History 1 commit
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ 25 days ago