{"version":3,"file":"token.mjs","names":[],"sources":["../../../../../src/astro/routes/api/oauth/token.ts"],"sourcesContent":["/**\n * POST /_emdash/api/oauth/token\n *\n * Unified token endpoint per OAuth 2.1. Routes by `grant_type`:\n * - authorization_code: Authorization Code + PKCE exchange\n * - urn:ietf:params:oauth:grant-type:device_code: Device Flow\n * - refresh_token: Token refresh\n *\n * Accepts both application/x-www-form-urlencoded (spec-standard) and\n * application/json (for backwards compatibility with existing clients).\n *\n * This is an unauthenticated endpoint — callers present tokens/codes\n * instead of session cookies.\n */\n\nimport type { APIRoute } from \"astro\";\nimport { z } from \"zod\";\n\nimport { apiError, handleError } from \"#api/error.js\";\nimport { handleDeviceTokenExchange, handleTokenRefresh } from \"#api/handlers/device-flow.js\";\nimport { handleAuthorizationCodeExchange } from \"#api/handlers/oauth-authorization.js\";\n\nexport const prerender = false;\n\n// ---------------------------------------------------------------------------\n// Parse helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Parse the request body from either form-encoded or JSON.\n * OAuth 2.1 mandates form-encoded, but we accept both.\n */\nasync function parseTokenBody(request: Request): Promise<Record<string, string>> {\n\tconst contentType = request.headers.get(\"content-type\") ?? \"\";\n\n\tif (contentType.includes(\"application/x-www-form-urlencoded\")) {\n\t\tconst text = await request.text();\n\t\tconst params = new URLSearchParams(text);\n\t\tconst result: Record<string, string> = {};\n\t\tfor (const [key, value] of params) {\n\t\t\tresult[key] = value;\n\t\t}\n\t\treturn result;\n\t}\n\n\t// Fallback: try JSON\n\ttry {\n\t\tconst json = Object(await request.json()) as Record<string, unknown>;\n\t\tconst result: Record<string, string> = {};\n\t\tfor (const [key, value] of Object.entries(json)) {\n\t\t\tif (typeof value === \"string\") {\n\t\t\t\tresult[key] = value;\n\t\t\t} else if (typeof value === \"number\") {\n\t\t\t\tresult[key] = String(value);\n\t\t\t}\n\t\t}\n\t\treturn result;\n\t} catch {\n\t\treturn {};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Schemas\n// ---------------------------------------------------------------------------\n\nconst authCodeSchema = z.object({\n\tgrant_type: z.literal(\"authorization_code\"),\n\tcode: z.string().min(1),\n\tredirect_uri: z.string().min(1),\n\tclient_id: z.string().min(1),\n\tcode_verifier: z.string().min(43).max(128),\n\tresource: z.string().optional(),\n});\n\nconst deviceCodeSchema = z.object({\n\tgrant_type: z.literal(\"urn:ietf:params:oauth:grant-type:device_code\"),\n\tdevice_code: z.string().min(1),\n});\n\nconst refreshSchema = z.object({\n\tgrant_type: z.literal(\"refresh_token\"),\n\trefresh_token: z.string().min(1),\n});\n\n// ---------------------------------------------------------------------------\n// Handler\n// ---------------------------------------------------------------------------\n\nexport const OPTIONS: APIRoute = () => {\n\treturn new Response(null, { status: 204, headers: OAUTH_PREFLIGHT_HEADERS });\n};\n\nexport const POST: APIRoute = async ({ request, locals }) => {\n\tconst { emdash } = locals;\n\n\tif (!emdash?.db) {\n\t\treturn apiError(\"NOT_CONFIGURED\", \"EmDash is not initialized\", 500);\n\t}\n\n\ttry {\n\t\tconst body = await parseTokenBody(request);\n\t\tconst grantType = body.grant_type;\n\n\t\tif (!grantType) {\n\t\t\treturn oauthError(\"invalid_request\", \"grant_type is required\", 400);\n\t\t}\n\n\t\tswitch (grantType) {\n\t\t\tcase \"authorization_code\": {\n\t\t\t\tconst parsed = authCodeSchema.safeParse(body);\n\t\t\t\tif (!parsed.success) {\n\t\t\t\t\treturn oauthError(\"invalid_request\", formatZodError(parsed.error), 400);\n\t\t\t\t}\n\n\t\t\t\tconst result = await handleAuthorizationCodeExchange(emdash.db, parsed.data);\n\t\t\t\tif (!result.success) {\n\t\t\t\t\tconst err = result.error ?? { code: \"unknown\", message: \"Unknown error\" };\n\t\t\t\t\treturn oauthError(err.code, err.message, 400);\n\t\t\t\t}\n\t\t\t\treturn oauthSuccess(result.data);\n\t\t\t}\n\n\t\t\tcase \"urn:ietf:params:oauth:grant-type:device_code\": {\n\t\t\t\tconst parsed = deviceCodeSchema.safeParse(body);\n\t\t\t\tif (!parsed.success) {\n\t\t\t\t\treturn oauthError(\"invalid_request\", formatZodError(parsed.error), 400);\n\t\t\t\t}\n\n\t\t\t\tconst result = await handleDeviceTokenExchange(emdash.db, parsed.data);\n\t\t\t\tif (!result.success) {\n\t\t\t\t\tconst err = result.error ?? { code: \"unknown\", message: \"Unknown error\" };\n\t\t\t\t\t// RFC 8628 requires specific error format\n\t\t\t\t\tif (result.deviceFlowError) {\n\t\t\t\t\t\treturn oauthError(result.deviceFlowError, err.message, 400);\n\t\t\t\t\t}\n\t\t\t\t\treturn oauthError(err.code, err.message, 400);\n\t\t\t\t}\n\t\t\t\treturn oauthSuccess(result.data);\n\t\t\t}\n\n\t\t\tcase \"refresh_token\": {\n\t\t\t\tconst parsed = refreshSchema.safeParse(body);\n\t\t\t\tif (!parsed.success) {\n\t\t\t\t\treturn oauthError(\"invalid_request\", formatZodError(parsed.error), 400);\n\t\t\t\t}\n\n\t\t\t\tconst result = await handleTokenRefresh(emdash.db, parsed.data);\n\t\t\t\tif (!result.success) {\n\t\t\t\t\tconst err = result.error ?? { code: \"unknown\", message: \"Unknown error\" };\n\t\t\t\t\treturn oauthError(err.code, err.message, 400);\n\t\t\t\t}\n\t\t\t\treturn oauthSuccess(result.data);\n\t\t\t}\n\n\t\t\tdefault:\n\t\t\t\treturn oauthError(\"unsupported_grant_type\", `Unsupported grant_type: ${grantType}`, 400);\n\t\t}\n\t} catch (error) {\n\t\treturn handleError(error, \"Failed to process token request\", \"TOKEN_ERROR\");\n\t}\n};\n\n// ---------------------------------------------------------------------------\n// OAuth response helpers (RFC 6749 §5.1 / §5.2)\n// ---------------------------------------------------------------------------\n\n/** RFC 6749 §5.1 requires Cache-Control: no-store and Pragma: no-cache on token responses */\nconst OAUTH_TOKEN_HEADERS: HeadersInit = {\n\t\"Content-Type\": \"application/json\",\n\t\"Cache-Control\": \"no-store\",\n\tPragma: \"no-cache\",\n\t// OAuth 2.1 token endpoint is called cross-origin by external clients. Caller\n\t// must present PKCE code_verifier / device_code / refresh_token on each request,\n\t// so there is no ambient credential for CSRF to exploit.\n\t\"Access-Control-Allow-Origin\": \"*\",\n};\n\nconst OAUTH_PREFLIGHT_HEADERS: HeadersInit = {\n\t\"Access-Control-Allow-Origin\": \"*\",\n\t\"Access-Control-Allow-Methods\": \"POST, OPTIONS\",\n\t\"Access-Control-Allow-Headers\": \"Content-Type\",\n\t\"Access-Control-Max-Age\": \"86400\",\n};\n\nfunction oauthSuccess(data: unknown): Response {\n\treturn Response.json(data, { headers: OAUTH_TOKEN_HEADERS });\n}\n\nfunction oauthError(error: string, description: string, status: number): Response {\n\treturn Response.json(\n\t\t{ error, error_description: description },\n\t\t{ status, headers: OAUTH_TOKEN_HEADERS },\n\t);\n}\n\nfunction formatZodError(error: z.ZodError): string {\n\treturn error.issues.map((i) => `${i.path.join(\".\")}: ${i.message}`).join(\"; \");\n}\n"],"mappings":"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"}