| 1 | import { createHash } from 'node:crypto';
|
| 2 | import { auth0Config } from '../apps/api/src/auth0.js';
|
| 3 | import {
|
| 4 | oauthStore,
|
| 5 | randomOAuthValue,
|
| 6 | type OAuthStore,
|
| 7 | } from '../apps/api/src/oauth-store.js';
|
| 8 | import { DEFAULT_MCP_AUTH_SCOPES } from '../apps/api/src/access-control.js';
|
| 9 | import { assertProductionConfiguration } from '../apps/api/src/production-config.js';
|
| 10 |
|
| 11 | assertProductionConfiguration('auth');
|
| 12 |
|
| 13 | const allowedScopes = new Set<string>(DEFAULT_MCP_AUTH_SCOPES);
|
| 14 |
|
| 15 | function oauthError(error: string, description: string, status = 400): Response {
|
| 16 | return Response.json({ error, error_description: description }, { status });
|
| 17 | }
|
| 18 |
|
| 19 | export async function authorize(request: Request, store: OAuthStore): Promise<Response> {
|
| 20 | const url = new URL(request.url);
|
| 21 | const clientId = url.searchParams.get('client_id');
|
| 22 | const redirectUri = url.searchParams.get('redirect_uri');
|
| 23 | const responseType = url.searchParams.get('response_type');
|
| 24 | const codeChallenge = url.searchParams.get('code_challenge');
|
| 25 | const challengeMethod = url.searchParams.get('code_challenge_method');
|
| 26 | if (!clientId || !redirectUri)
|
| 27 | return oauthError('invalid_request', 'Missing client or redirect URI');
|
| 28 | const client = await store.getClient(clientId);
|
| 29 | if (!client || !client.redirectUris.includes(redirectUri))
|
| 30 | return oauthError('invalid_request', 'Unknown client or redirect URI');
|
| 31 | if (responseType !== 'code') return oauthError('unsupported_response_type', 'Use code');
|
| 32 | if (!codeChallenge || challengeMethod !== 'S256')
|
| 33 | return oauthError('invalid_request', 'PKCE with S256 is required');
|
| 34 |
|
| 35 | const requestedScopes = (url.searchParams.get('scope') ?? DEFAULT_MCP_AUTH_SCOPES.join(' '))
|
| 36 | .split(/\s+/)
|
| 37 | .filter(Boolean);
|
| 38 | if (!requestedScopes.length || requestedScopes.some((scope) => !allowedScopes.has(scope)))
|
| 39 | return oauthError('invalid_scope', 'One or more requested OpenClasp scopes are unsupported');
|
| 40 |
|
| 41 | const transactionId = randomOAuthValue('oc_tx_');
|
| 42 | const auth0Verifier = randomOAuthValue('');
|
| 43 | const auth0Challenge = createHash('sha256').update(auth0Verifier).digest('base64url');
|
| 44 | await store.createTransaction({
|
| 45 | transactionId,
|
| 46 | clientId,
|
| 47 | redirectUri,
|
| 48 | ...(url.searchParams.has('state') ? { downstreamState: url.searchParams.get('state')! } : {}),
|
| 49 | scope: requestedScopes.join(' '),
|
| 50 | codeChallenge,
|
| 51 | auth0Verifier,
|
| 52 | });
|
| 53 |
|
| 54 | const config = auth0Config();
|
| 55 | const auth0Url = new URL(`${config.issuer}authorize`);
|
| 56 | auth0Url.search = new URLSearchParams({
|
| 57 | client_id: config.dashboardClientId,
|
| 58 | redirect_uri: `${config.publicOrigin}/sso-callback`,
|
| 59 | response_type: 'code',
|
| 60 | scope: 'openid profile email',
|
| 61 | audience: config.audience,
|
| 62 | state: transactionId,
|
| 63 | code_challenge: auth0Challenge,
|
| 64 | code_challenge_method: 'S256',
|
| 65 | }).toString();
|
| 66 | return Response.redirect(auth0Url, 302);
|
| 67 | }
|
| 68 |
|
| 69 | export async function GET(request: Request): Promise<Response> {
|
| 70 | try {
|
| 71 | return await authorize(request, oauthStore());
|
| 72 | } catch {
|
| 73 | return oauthError('temporarily_unavailable', 'OAuth service unavailable', 503);
|
| 74 | }
|
| 75 | }
|