@earendil-works/pi-ai 0.82.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +2 -1
  2. package/dist/api/anthropic-messages.d.ts.map +1 -1
  3. package/dist/api/anthropic-messages.js +11 -4
  4. package/dist/api/anthropic-messages.js.map +1 -1
  5. package/dist/api/azure-openai-responses.d.ts.map +1 -1
  6. package/dist/api/azure-openai-responses.js +5 -1
  7. package/dist/api/azure-openai-responses.js.map +1 -1
  8. package/dist/api/bedrock-converse-stream.d.ts.map +1 -1
  9. package/dist/api/bedrock-converse-stream.js +24 -7
  10. package/dist/api/bedrock-converse-stream.js.map +1 -1
  11. package/dist/api/google-generative-ai.d.ts.map +1 -1
  12. package/dist/api/google-generative-ai.js +12 -2
  13. package/dist/api/google-generative-ai.js.map +1 -1
  14. package/dist/api/google-vertex.d.ts.map +1 -1
  15. package/dist/api/google-vertex.js +12 -2
  16. package/dist/api/google-vertex.js.map +1 -1
  17. package/dist/api/mistral-conversations.d.ts.map +1 -1
  18. package/dist/api/mistral-conversations.js +19 -10
  19. package/dist/api/mistral-conversations.js.map +1 -1
  20. package/dist/api/openai-codex-responses.d.ts.map +1 -1
  21. package/dist/api/openai-codex-responses.js +12 -2
  22. package/dist/api/openai-codex-responses.js.map +1 -1
  23. package/dist/api/openai-completions.d.ts.map +1 -1
  24. package/dist/api/openai-completions.js +21 -9
  25. package/dist/api/openai-completions.js.map +1 -1
  26. package/dist/api/openai-responses-shared.d.ts.map +1 -1
  27. package/dist/api/openai-responses-shared.js +11 -1
  28. package/dist/api/openai-responses-shared.js.map +1 -1
  29. package/dist/api/openai-responses.d.ts.map +1 -1
  30. package/dist/api/openai-responses.js +7 -3
  31. package/dist/api/openai-responses.js.map +1 -1
  32. package/dist/api/openrouter-images.d.ts.map +1 -1
  33. package/dist/api/openrouter-images.js +3 -2
  34. package/dist/api/openrouter-images.js.map +1 -1
  35. package/dist/api/pi-messages.d.ts.map +1 -1
  36. package/dist/api/pi-messages.js +2 -2
  37. package/dist/api/pi-messages.js.map +1 -1
  38. package/dist/api/simple-options.d.ts.map +1 -1
  39. package/dist/api/simple-options.js +1 -0
  40. package/dist/api/simple-options.js.map +1 -1
  41. package/dist/auth/oauth/openrouter.d.ts +3 -1
  42. package/dist/auth/oauth/openrouter.d.ts.map +1 -1
  43. package/dist/auth/oauth/openrouter.js +81 -26
  44. package/dist/auth/oauth/openrouter.js.map +1 -1
  45. package/dist/auth/oauth/radius.d.ts +3 -2
  46. package/dist/auth/oauth/radius.d.ts.map +1 -1
  47. package/dist/auth/oauth/radius.js +37 -35
  48. package/dist/auth/oauth/radius.js.map +1 -1
  49. package/dist/auth/resolve.d.ts +2 -0
  50. package/dist/auth/resolve.d.ts.map +1 -1
  51. package/dist/auth/resolve.js +27 -9
  52. package/dist/auth/resolve.js.map +1 -1
  53. package/dist/env-api-keys.d.ts +3 -0
  54. package/dist/env-api-keys.d.ts.map +1 -1
  55. package/dist/env-api-keys.js +9 -3
  56. package/dist/env-api-keys.js.map +1 -1
  57. package/dist/models-store.d.ts +5 -0
  58. package/dist/models-store.d.ts.map +1 -1
  59. package/dist/models-store.js.map +1 -1
  60. package/dist/providers/anthropic.d.ts.map +1 -1
  61. package/dist/providers/anthropic.js +30 -3
  62. package/dist/providers/anthropic.js.map +1 -1
  63. package/dist/providers/data/.manifest.json +1 -1
  64. package/dist/providers/data/amazon-bedrock.json +1 -1
  65. package/dist/providers/data/anthropic.json +1 -1
  66. package/dist/providers/data/azure-openai-responses.json +1 -1
  67. package/dist/providers/data/cloudflare-ai-gateway.json +1 -1
  68. package/dist/providers/data/fireworks.json +1 -1
  69. package/dist/providers/data/github-copilot.json +1 -1
  70. package/dist/providers/data/google.json +1 -1
  71. package/dist/providers/data/huggingface.json +1 -1
  72. package/dist/providers/data/kimi-coding.json +1 -1
  73. package/dist/providers/data/nvidia.json +1 -1
  74. package/dist/providers/data/openai.json +1 -1
  75. package/dist/providers/data/opencode.json +1 -1
  76. package/dist/providers/data/openrouter.json +1 -1
  77. package/dist/providers/data/qwen-token-plan-cn.json +1 -1
  78. package/dist/providers/data/qwen-token-plan.json +1 -1
  79. package/dist/providers/data/together.json +1 -1
  80. package/dist/providers/data/vercel-ai-gateway.json +1 -1
  81. package/dist/providers/data/zai-coding-cn.json +1 -1
  82. package/dist/providers/data/zai.json +1 -1
  83. package/dist/providers/faux.d.ts.map +1 -1
  84. package/dist/providers/faux.js +4 -1
  85. package/dist/providers/faux.js.map +1 -1
  86. package/dist/types.d.ts +11 -1
  87. package/dist/types.d.ts.map +1 -1
  88. package/dist/types.js.map +1 -1
  89. package/dist/utils/error-body.d.ts.map +1 -1
  90. package/dist/utils/error-body.js +8 -3
  91. package/dist/utils/error-body.js.map +1 -1
  92. package/package.json +2 -2
@@ -1 +1 @@
1
- {"version":3,"file":"openrouter.d.ts","sourceRoot":"","sources":["../../../src/auth/oauth/openrouter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAmB,SAAS,EAAmB,MAAM,aAAa,CAAC;AA8N/E,eAAO,MAAM,eAAe,EAAE,SAU7B,CAAC","sourcesContent":["/**\n * OpenRouter OAuth PKCE flow.\n *\n * OpenRouter exchanges an authorization code for a permanent, user-controlled\n * API key rather than an expiring access/refresh token pair. The callback is\n * handled by a one-shot loopback server on an ephemeral port.\n *\n * NOTE: This module uses Node.js http.createServer for the OAuth callback server.\n * It is only intended for CLI use, not browser environments.\n */\n\nimport { createServer, type Server, type ServerResponse } from \"node:http\";\nimport { getProviderEnvValue } from \"../../utils/provider-env.ts\";\nimport type { AuthInteraction, OAuthAuth, OAuthCredential } from \"../types.ts\";\nimport { oauthErrorHtml, oauthSuccessHtml } from \"./oauth-page.ts\";\nimport { generatePKCE } from \"./pkce.ts\";\n\nconst AUTHORIZE_URL = \"https://openrouter.ai/auth\";\nconst TOKEN_URL = \"https://openrouter.ai/api/v1/auth/keys\";\nconst LOGIN_TIMEOUT_MS = 5 * 60 * 1000;\nconst TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;\n\nfunction getCallbackHost(): string {\n\treturn getProviderEnvValue(\"PI_OAUTH_CALLBACK_HOST\") || \"127.0.0.1\";\n}\n\ntype JsonObject = Record<string, unknown>;\n\ntype OpenRouterCallbackServer = {\n\tcallbackUrl: string;\n\tcredential: Promise<OAuthCredential>;\n\tclose(): void;\n};\n\nfunction sendHtml(response: ServerResponse, status: number, html: string): void {\n\tresponse.statusCode = status;\n\tresponse.setHeader(\"content-type\", \"text/html; charset=utf-8\");\n\tresponse.setHeader(\"cache-control\", \"no-store\");\n\tresponse.end(html);\n}\n\nfunction errorDetail(body: JsonObject): string | undefined {\n\tif (typeof body.error_description === \"string\") return body.error_description;\n\tif (typeof body.message === \"string\") return body.message;\n\tif (typeof body.error === \"string\") return body.error;\n\tif (body.error && typeof body.error === \"object\" && !Array.isArray(body.error)) {\n\t\tconst message = (body.error as JsonObject).message;\n\t\tif (typeof message === \"string\") return message;\n\t}\n\treturn undefined;\n}\n\nasync function exchangeAuthorizationCode(\n\tcode: string,\n\tverifier: string,\n\tsignal?: AbortSignal,\n): Promise<OAuthCredential> {\n\tif (signal?.aborted) throw new Error(\"Login cancelled\");\n\tconst controller = new AbortController();\n\tconst onAbort = () => controller.abort(signal?.reason);\n\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\tconst timeout = setTimeout(\n\t\t() => controller.abort(new Error(\"OpenRouter OAuth token exchange timed out\")),\n\t\tTOKEN_EXCHANGE_TIMEOUT_MS,\n\t);\n\n\tlet response: Response;\n\tlet body: JsonObject = {};\n\ttry {\n\t\tresponse = await fetch(TOKEN_URL, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { accept: \"application/json\", \"content-type\": \"application/json\" },\n\t\t\tbody: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: \"S256\" }),\n\t\t\tsignal: controller.signal,\n\t\t});\n\t\ttry {\n\t\t\tconst parsed = (await response.json()) as unknown;\n\t\t\tif (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) body = parsed as JsonObject;\n\t\t} catch {\n\t\t\tif (response.ok) throw new Error(\"OpenRouter OAuth returned invalid JSON\");\n\t\t}\n\t} catch (error) {\n\t\tif (signal?.aborted) throw new Error(\"Login cancelled\");\n\t\tif (controller.signal.aborted) throw new Error(\"OpenRouter OAuth token exchange timed out\");\n\t\tthrow error;\n\t} finally {\n\t\tclearTimeout(timeout);\n\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t}\n\n\tif (!response.ok) {\n\t\tconst detail = errorDetail(body);\n\t\tthrow new Error(`OpenRouter OAuth key exchange failed (HTTP ${response.status})${detail ? `: ${detail}` : \"\"}`);\n\t}\n\n\tif (typeof body.key !== \"string\" || body.key.length === 0) {\n\t\tthrow new Error('OpenRouter OAuth response carries no \"key\"');\n\t}\n\n\treturn {\n\t\ttype: \"oauth\",\n\t\taccess: body.key,\n\t\trefresh: \"\",\n\t\texpires: Number.MAX_SAFE_INTEGER,\n\t};\n}\n\nasync function startCallbackServer(\n\tcallbackPath: string,\n\tverifier: string,\n\tsignal?: AbortSignal,\n): Promise<OpenRouterCallbackServer> {\n\tif (signal?.aborted) throw new Error(\"Login cancelled\");\n\tconst callbackHost = getCallbackHost();\n\tlet resolveCredential: (credential: OAuthCredential) => void = () => {};\n\tlet rejectCredential: (error: Error) => void = () => {};\n\tconst credential = new Promise<OAuthCredential>((resolve, reject) => {\n\t\tresolveCredential = resolve;\n\t\trejectCredential = reject;\n\t});\n\n\tlet server: Server;\n\tlet claimed = false;\n\tlet settled = false;\n\tlet timeout: ReturnType<typeof setTimeout> | undefined;\n\tlet onAbort: (() => void) | undefined;\n\n\tconst finish = (result: { credential: OAuthCredential } | { error: Error }): void => {\n\t\tif (settled) return;\n\t\tsettled = true;\n\t\tif (timeout) clearTimeout(timeout);\n\t\tif (onAbort) signal?.removeEventListener(\"abort\", onAbort);\n\t\tserver.close();\n\t\tif (\"credential\" in result) resolveCredential(result.credential);\n\t\telse rejectCredential(result.error);\n\t};\n\n\tserver = createServer((request, response) => {\n\t\tvoid (async () => {\n\t\t\tconst requestUrl = new URL(request.url ?? \"/\", `http://${callbackHost}`);\n\t\t\tif (request.method !== \"GET\" || requestUrl.pathname !== callbackPath) {\n\t\t\t\tsendHtml(response, 404, oauthErrorHtml(\"OAuth callback route not found.\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (claimed || settled) {\n\t\t\t\tsendHtml(response, 409, oauthErrorHtml(\"This OAuth callback has already been used.\"));\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst oauthError = requestUrl.searchParams.get(\"error\");\n\t\t\tif (oauthError) {\n\t\t\t\tconst description = requestUrl.searchParams.get(\"error_description\") ?? oauthError;\n\t\t\t\tsendHtml(response, 400, oauthErrorHtml(\"OpenRouter authorization was denied.\", description));\n\t\t\t\tfinish({ error: new Error(`OpenRouter authorization failed: ${description}`) });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst code = requestUrl.searchParams.get(\"code\");\n\t\t\tif (!code) {\n\t\t\t\tsendHtml(response, 400, oauthErrorHtml(\"OpenRouter returned no authorization code.\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tclaimed = true;\n\n\t\t\ttry {\n\t\t\t\tconst result = await exchangeAuthorizationCode(code, verifier, signal);\n\t\t\t\tsendHtml(response, 200, oauthSuccessHtml(\"Signed in to OpenRouter. You may now close this page.\"));\n\t\t\t\tfinish({ credential: result });\n\t\t\t} catch (error) {\n\t\t\t\tconst message = error instanceof Error ? error.message : \"Unknown token exchange error\";\n\t\t\t\tsendHtml(response, 502, oauthErrorHtml(\"OpenRouter key exchange failed.\", message));\n\t\t\t\tfinish({ error: error instanceof Error ? error : new Error(message) });\n\t\t\t}\n\t\t})();\n\t});\n\n\tawait new Promise<void>((resolve, reject) => {\n\t\tserver.once(\"error\", reject);\n\t\tserver.listen(0, callbackHost, () => {\n\t\t\tserver.removeListener(\"error\", reject);\n\t\t\tresolve();\n\t\t});\n\t});\n\n\tserver.on(\"error\", (error) => finish({ error }));\n\tonAbort = () => finish({ error: new Error(\"Login cancelled\") });\n\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\tif (signal?.aborted) {\n\t\tsignal.removeEventListener(\"abort\", onAbort);\n\t\tserver.close();\n\t\tthrow new Error(\"Login cancelled\");\n\t}\n\ttimeout = setTimeout(() => finish({ error: new Error(\"OpenRouter OAuth login timed out\") }), LOGIN_TIMEOUT_MS);\n\n\tconst address = server.address();\n\tif (!address || typeof address === \"string\") {\n\t\tfinish({ error: new Error(\"Could not determine the OpenRouter OAuth callback port\") });\n\t\tthrow new Error(\"Could not determine the OpenRouter OAuth callback port\");\n\t}\n\n\treturn {\n\t\tcallbackUrl: `http://${callbackHost}:${address.port}${callbackPath}`,\n\t\tcredential,\n\t\tclose: () => finish({ error: new Error(\"Login cancelled\") }),\n\t};\n}\n\nasync function loginOpenRouter(interaction: AuthInteraction): Promise<OAuthCredential> {\n\tconst { verifier, challenge } = await generatePKCE();\n\tconst callbackPath = `/oauth/callback/${crypto.randomUUID()}`;\n\tconst callback = await startCallbackServer(callbackPath, verifier, interaction.signal);\n\tconst authorizeUrl = new URL(AUTHORIZE_URL);\n\tauthorizeUrl.search = new URLSearchParams({\n\t\tcallback_url: callback.callbackUrl,\n\t\tcode_challenge: challenge,\n\t\tcode_challenge_method: \"S256\",\n\t}).toString();\n\n\tinteraction.notify({\n\t\ttype: \"progress\",\n\t\tmessage: `Listening for OpenRouter OAuth callback on ${callback.callbackUrl}`,\n\t});\n\tinteraction.notify({\n\t\ttype: \"auth_url\",\n\t\turl: authorizeUrl.toString(),\n\t\tinstructions: \"Complete sign-in in your browser.\",\n\t});\n\n\ttry {\n\t\treturn await callback.credential;\n\t} finally {\n\t\tcallback.close();\n\t}\n}\n\nexport const openRouterOAuth: OAuthAuth = {\n\tname: \"OpenRouter OAuth\",\n\tloginLabel: \"Sign in with OpenRouter\",\n\tlogin: loginOpenRouter,\n\tasync refresh(credential) {\n\t\treturn credential;\n\t},\n\tasync toAuth(credential) {\n\t\treturn { apiKey: credential.access };\n\t},\n};\n"]}
1
+ {"version":3,"file":"openrouter.d.ts","sourceRoot":"","sources":["../../../src/auth/oauth/openrouter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,OAAO,KAAK,EAAmB,SAAS,EAAmB,MAAM,aAAa,CAAC;AA6R/E,eAAO,MAAM,eAAe,EAAE,SAU7B,CAAC","sourcesContent":["/**\n * OpenRouter OAuth PKCE flow.\n *\n * OpenRouter exchanges an authorization code for a permanent, user-controlled\n * API key rather than an expiring access/refresh token pair. The callback is\n * handled by a one-shot loopback server on an ephemeral port, raced against a\n * manual prompt so remote/headless sessions can paste the redirect URL when\n * the browser cannot reach the loopback server.\n *\n * NOTE: This module uses Node.js http.createServer for the OAuth callback server.\n * It is only intended for CLI use, not browser environments.\n */\n\nimport { createServer, type Server, type ServerResponse } from \"node:http\";\nimport { getProviderEnvValue } from \"../../utils/provider-env.ts\";\nimport type { AuthInteraction, OAuthAuth, OAuthCredential } from \"../types.ts\";\nimport { oauthErrorHtml, oauthSuccessHtml } from \"./oauth-page.ts\";\nimport { generatePKCE } from \"./pkce.ts\";\n\nconst AUTHORIZE_URL = \"https://openrouter.ai/auth\";\nconst TOKEN_URL = \"https://openrouter.ai/api/v1/auth/keys\";\nconst LOGIN_TIMEOUT_MS = 5 * 60 * 1000;\nconst TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;\n\nfunction getCallbackHost(): string {\n\treturn getProviderEnvValue(\"PI_OAUTH_CALLBACK_HOST\") || \"127.0.0.1\";\n}\n\ntype JsonObject = Record<string, unknown>;\n\ntype OpenRouterCallbackServer = {\n\tcallbackUrl: string;\n\t/** Stop listening and release timers without settling `waitForCredential`. */\n\tclose: () => void;\n\t/** Hand the login over to manual code entry unless a callback already claimed the exchange. */\n\tcancelWait: () => void;\n\t/**\n\t * Resolves with the credential once a browser callback completes the key\n\t * exchange, or with null once `cancelWait` hands the login over to manual\n\t * code entry. Rejects on timeout, cancellation, or a failed exchange.\n\t */\n\twaitForCredential: () => Promise<OAuthCredential | null>;\n};\n\nfunction sendHtml(response: ServerResponse, status: number, html: string): void {\n\tresponse.statusCode = status;\n\tresponse.setHeader(\"content-type\", \"text/html; charset=utf-8\");\n\tresponse.setHeader(\"cache-control\", \"no-store\");\n\tresponse.end(html);\n}\n\nfunction parseAuthorizationInput(input: string): string | undefined {\n\tconst value = input.trim();\n\tif (!value) return undefined;\n\n\ttry {\n\t\treturn new URL(value).searchParams.get(\"code\") ?? undefined;\n\t} catch {\n\t\t// not a URL\n\t}\n\n\tif (value.includes(\"code=\")) {\n\t\treturn new URLSearchParams(value).get(\"code\") ?? undefined;\n\t}\n\n\treturn value;\n}\n\nfunction errorDetail(body: JsonObject): string | undefined {\n\tif (typeof body.error_description === \"string\") return body.error_description;\n\tif (typeof body.message === \"string\") return body.message;\n\tif (typeof body.error === \"string\") return body.error;\n\tif (body.error && typeof body.error === \"object\" && !Array.isArray(body.error)) {\n\t\tconst message = (body.error as JsonObject).message;\n\t\tif (typeof message === \"string\") return message;\n\t}\n\treturn undefined;\n}\n\nasync function exchangeAuthorizationCode(\n\tcode: string,\n\tverifier: string,\n\tsignal?: AbortSignal,\n): Promise<OAuthCredential> {\n\tif (signal?.aborted) throw new Error(\"Login cancelled\");\n\tconst controller = new AbortController();\n\tconst onAbort = () => controller.abort(signal?.reason);\n\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\tconst timeout = setTimeout(\n\t\t() => controller.abort(new Error(\"OpenRouter OAuth token exchange timed out\")),\n\t\tTOKEN_EXCHANGE_TIMEOUT_MS,\n\t);\n\n\tlet response: Response;\n\tlet body: JsonObject = {};\n\ttry {\n\t\tresponse = await fetch(TOKEN_URL, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { accept: \"application/json\", \"content-type\": \"application/json\" },\n\t\t\tbody: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: \"S256\" }),\n\t\t\tsignal: controller.signal,\n\t\t});\n\t\ttry {\n\t\t\tconst parsed = (await response.json()) as unknown;\n\t\t\tif (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) body = parsed as JsonObject;\n\t\t} catch {\n\t\t\tif (response.ok) throw new Error(\"OpenRouter OAuth returned invalid JSON\");\n\t\t}\n\t} catch (error) {\n\t\tif (signal?.aborted) throw new Error(\"Login cancelled\");\n\t\tif (controller.signal.aborted) throw new Error(\"OpenRouter OAuth token exchange timed out\");\n\t\tthrow error;\n\t} finally {\n\t\tclearTimeout(timeout);\n\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t}\n\n\tif (!response.ok) {\n\t\tconst detail = errorDetail(body);\n\t\tthrow new Error(`OpenRouter OAuth key exchange failed (HTTP ${response.status})${detail ? `: ${detail}` : \"\"}`);\n\t}\n\n\tif (typeof body.key !== \"string\" || body.key.length === 0) {\n\t\tthrow new Error('OpenRouter OAuth response carries no \"key\"');\n\t}\n\n\treturn {\n\t\ttype: \"oauth\",\n\t\taccess: body.key,\n\t\trefresh: \"\",\n\t\texpires: Number.MAX_SAFE_INTEGER,\n\t};\n}\n\nasync function startCallbackServer(\n\tcallbackPath: string,\n\tverifier: string,\n\tsignal?: AbortSignal,\n): Promise<OpenRouterCallbackServer> {\n\tif (signal?.aborted) throw new Error(\"Login cancelled\");\n\tconst callbackHost = getCallbackHost();\n\tlet resolveCredential: (credential: OAuthCredential | null) => void = () => {};\n\tlet rejectCredential: (error: Error) => void = () => {};\n\tconst credential = new Promise<OAuthCredential | null>((resolve, reject) => {\n\t\tresolveCredential = resolve;\n\t\trejectCredential = reject;\n\t});\n\n\tlet server: Server;\n\tlet claimed = false;\n\tlet settled = false;\n\tlet timeout: ReturnType<typeof setTimeout> | undefined;\n\tlet onAbort: (() => void) | undefined;\n\n\tconst close = (): void => {\n\t\tif (timeout) clearTimeout(timeout);\n\t\tif (onAbort) signal?.removeEventListener(\"abort\", onAbort);\n\t\tserver.close();\n\t};\n\n\tconst finish = (result: { credential: OAuthCredential | null } | { error: Error }): void => {\n\t\tif (settled) return;\n\t\tsettled = true;\n\t\tclose();\n\t\tif (\"credential\" in result) resolveCredential(result.credential);\n\t\telse rejectCredential(result.error);\n\t};\n\n\tserver = createServer((request, response) => {\n\t\tvoid (async () => {\n\t\t\tconst requestUrl = new URL(request.url ?? \"/\", `http://${callbackHost}`);\n\t\t\tif (request.method !== \"GET\" || requestUrl.pathname !== callbackPath) {\n\t\t\t\tsendHtml(response, 404, oauthErrorHtml(\"OAuth callback route not found.\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (claimed || settled) {\n\t\t\t\tsendHtml(response, 409, oauthErrorHtml(\"This OAuth callback has already been used.\"));\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst oauthError = requestUrl.searchParams.get(\"error\");\n\t\t\tif (oauthError) {\n\t\t\t\tconst description = requestUrl.searchParams.get(\"error_description\") ?? oauthError;\n\t\t\t\tsendHtml(response, 400, oauthErrorHtml(\"OpenRouter authorization was denied.\", description));\n\t\t\t\tfinish({ error: new Error(`OpenRouter authorization failed: ${description}`) });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst code = requestUrl.searchParams.get(\"code\");\n\t\t\tif (!code) {\n\t\t\t\tsendHtml(response, 400, oauthErrorHtml(\"OpenRouter returned no authorization code.\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tclaimed = true;\n\n\t\t\ttry {\n\t\t\t\tconst result = await exchangeAuthorizationCode(code, verifier, signal);\n\t\t\t\tsendHtml(response, 200, oauthSuccessHtml(\"Signed in to OpenRouter. You may now close this page.\"));\n\t\t\t\tfinish({ credential: result });\n\t\t\t} catch (error) {\n\t\t\t\tconst message = error instanceof Error ? error.message : \"Unknown token exchange error\";\n\t\t\t\tsendHtml(response, 502, oauthErrorHtml(\"OpenRouter key exchange failed.\", message));\n\t\t\t\tfinish({ error: error instanceof Error ? error : new Error(message) });\n\t\t\t}\n\t\t})();\n\t});\n\n\tawait new Promise<void>((resolve, reject) => {\n\t\tserver.once(\"error\", reject);\n\t\tserver.listen(0, callbackHost, () => {\n\t\t\tserver.removeListener(\"error\", reject);\n\t\t\tresolve();\n\t\t});\n\t});\n\n\tserver.on(\"error\", (error) => finish({ error }));\n\tonAbort = () => finish({ error: new Error(\"Login cancelled\") });\n\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\tif (signal?.aborted) {\n\t\tclose();\n\t\tthrow new Error(\"Login cancelled\");\n\t}\n\ttimeout = setTimeout(() => finish({ error: new Error(\"OpenRouter OAuth login timed out\") }), LOGIN_TIMEOUT_MS);\n\n\tconst address = server.address();\n\tif (!address || typeof address === \"string\") {\n\t\tclose();\n\t\tthrow new Error(\"Could not determine the OpenRouter OAuth callback port\");\n\t}\n\n\treturn {\n\t\tcallbackUrl: `http://${callbackHost}:${address.port}${callbackPath}`,\n\t\tclose,\n\t\t// A claimed callback is already exchanging its code; let that exchange settle the login.\n\t\tcancelWait: () => {\n\t\t\tif (!claimed) finish({ credential: null });\n\t\t},\n\t\twaitForCredential: () => credential,\n\t};\n}\n\nasync function loginOpenRouter(interaction: AuthInteraction): Promise<OAuthCredential> {\n\tconst { verifier, challenge } = await generatePKCE();\n\tconst callbackPath = `/oauth/callback/${crypto.randomUUID()}`;\n\tconst callback = await startCallbackServer(callbackPath, verifier, interaction.signal);\n\tconst manualAbort = new AbortController();\n\tlet manualInput: string | undefined;\n\tlet manualError: Error | undefined;\n\n\ttry {\n\t\tconst authorizeUrl = new URL(AUTHORIZE_URL);\n\t\tauthorizeUrl.search = new URLSearchParams({\n\t\t\tcallback_url: callback.callbackUrl,\n\t\t\tcode_challenge: challenge,\n\t\t\tcode_challenge_method: \"S256\",\n\t\t}).toString();\n\n\t\tinteraction.notify({\n\t\t\ttype: \"progress\",\n\t\t\tmessage: `Listening for OpenRouter OAuth callback on ${callback.callbackUrl}`,\n\t\t});\n\t\tinteraction.notify({\n\t\t\ttype: \"auth_url\",\n\t\t\turl: authorizeUrl.toString(),\n\t\t\tinstructions:\n\t\t\t\t\"Complete sign-in in your browser. If the browser is on another machine, paste the final redirect URL here.\",\n\t\t});\n\n\t\tconst manualPromise = interaction\n\t\t\t.prompt({\n\t\t\t\ttype: \"manual_code\",\n\t\t\t\tmessage: \"Complete sign-in in your browser, or paste the authorization code / redirect URL here:\",\n\t\t\t\tplaceholder: callback.callbackUrl,\n\t\t\t\tsignal: manualAbort.signal,\n\t\t\t})\n\t\t\t.then((input) => {\n\t\t\t\tmanualInput = input;\n\t\t\t\tcallback.cancelWait();\n\t\t\t})\n\t\t\t.catch((error) => {\n\t\t\t\tmanualError = error instanceof Error ? error : new Error(String(error));\n\t\t\t\tcallback.cancelWait();\n\t\t\t});\n\n\t\tconst credential = await callback.waitForCredential();\n\t\tif (manualError) throw manualError;\n\t\tif (credential) return credential;\n\n\t\tawait manualPromise;\n\t\tif (manualError) throw manualError;\n\t\tconst code = manualInput ? parseAuthorizationInput(manualInput) : undefined;\n\t\tif (!code) throw new Error(\"Missing authorization code\");\n\t\tinteraction.notify({ type: \"progress\", message: \"Exchanging authorization code for an API key...\" });\n\t\treturn await exchangeAuthorizationCode(code, verifier, interaction.signal);\n\t} finally {\n\t\tmanualAbort.abort();\n\t\tcallback.close();\n\t}\n}\n\nexport const openRouterOAuth: OAuthAuth = {\n\tname: \"OpenRouter OAuth\",\n\tloginLabel: \"Sign in with OpenRouter\",\n\tlogin: loginOpenRouter,\n\tasync refresh(credential) {\n\t\treturn credential;\n\t},\n\tasync toAuth(credential) {\n\t\treturn { apiKey: credential.access };\n\t},\n};\n"]}
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * OpenRouter exchanges an authorization code for a permanent, user-controlled
5
5
  * API key rather than an expiring access/refresh token pair. The callback is
6
- * handled by a one-shot loopback server on an ephemeral port.
6
+ * handled by a one-shot loopback server on an ephemeral port, raced against a
7
+ * manual prompt so remote/headless sessions can paste the redirect URL when
8
+ * the browser cannot reach the loopback server.
7
9
  *
8
10
  * NOTE: This module uses Node.js http.createServer for the OAuth callback server.
9
11
  * It is only intended for CLI use, not browser environments.
@@ -25,6 +27,21 @@ function sendHtml(response, status, html) {
25
27
  response.setHeader("cache-control", "no-store");
26
28
  response.end(html);
27
29
  }
30
+ function parseAuthorizationInput(input) {
31
+ const value = input.trim();
32
+ if (!value)
33
+ return undefined;
34
+ try {
35
+ return new URL(value).searchParams.get("code") ?? undefined;
36
+ }
37
+ catch {
38
+ // not a URL
39
+ }
40
+ if (value.includes("code=")) {
41
+ return new URLSearchParams(value).get("code") ?? undefined;
42
+ }
43
+ return value;
44
+ }
28
45
  function errorDetail(body) {
29
46
  if (typeof body.error_description === "string")
30
47
  return body.error_description;
@@ -105,15 +122,18 @@ async function startCallbackServer(callbackPath, verifier, signal) {
105
122
  let settled = false;
106
123
  let timeout;
107
124
  let onAbort;
108
- const finish = (result) => {
109
- if (settled)
110
- return;
111
- settled = true;
125
+ const close = () => {
112
126
  if (timeout)
113
127
  clearTimeout(timeout);
114
128
  if (onAbort)
115
129
  signal?.removeEventListener("abort", onAbort);
116
130
  server.close();
131
+ };
132
+ const finish = (result) => {
133
+ if (settled)
134
+ return;
135
+ settled = true;
136
+ close();
117
137
  if ("credential" in result)
118
138
  resolveCredential(result.credential);
119
139
  else
@@ -166,45 +186,80 @@ async function startCallbackServer(callbackPath, verifier, signal) {
166
186
  onAbort = () => finish({ error: new Error("Login cancelled") });
167
187
  signal?.addEventListener("abort", onAbort, { once: true });
168
188
  if (signal?.aborted) {
169
- signal.removeEventListener("abort", onAbort);
170
- server.close();
189
+ close();
171
190
  throw new Error("Login cancelled");
172
191
  }
173
192
  timeout = setTimeout(() => finish({ error: new Error("OpenRouter OAuth login timed out") }), LOGIN_TIMEOUT_MS);
174
193
  const address = server.address();
175
194
  if (!address || typeof address === "string") {
176
- finish({ error: new Error("Could not determine the OpenRouter OAuth callback port") });
195
+ close();
177
196
  throw new Error("Could not determine the OpenRouter OAuth callback port");
178
197
  }
179
198
  return {
180
199
  callbackUrl: `http://${callbackHost}:${address.port}${callbackPath}`,
181
- credential,
182
- close: () => finish({ error: new Error("Login cancelled") }),
200
+ close,
201
+ // A claimed callback is already exchanging its code; let that exchange settle the login.
202
+ cancelWait: () => {
203
+ if (!claimed)
204
+ finish({ credential: null });
205
+ },
206
+ waitForCredential: () => credential,
183
207
  };
184
208
  }
185
209
  async function loginOpenRouter(interaction) {
186
210
  const { verifier, challenge } = await generatePKCE();
187
211
  const callbackPath = `/oauth/callback/${crypto.randomUUID()}`;
188
212
  const callback = await startCallbackServer(callbackPath, verifier, interaction.signal);
189
- const authorizeUrl = new URL(AUTHORIZE_URL);
190
- authorizeUrl.search = new URLSearchParams({
191
- callback_url: callback.callbackUrl,
192
- code_challenge: challenge,
193
- code_challenge_method: "S256",
194
- }).toString();
195
- interaction.notify({
196
- type: "progress",
197
- message: `Listening for OpenRouter OAuth callback on ${callback.callbackUrl}`,
198
- });
199
- interaction.notify({
200
- type: "auth_url",
201
- url: authorizeUrl.toString(),
202
- instructions: "Complete sign-in in your browser.",
203
- });
213
+ const manualAbort = new AbortController();
214
+ let manualInput;
215
+ let manualError;
204
216
  try {
205
- return await callback.credential;
217
+ const authorizeUrl = new URL(AUTHORIZE_URL);
218
+ authorizeUrl.search = new URLSearchParams({
219
+ callback_url: callback.callbackUrl,
220
+ code_challenge: challenge,
221
+ code_challenge_method: "S256",
222
+ }).toString();
223
+ interaction.notify({
224
+ type: "progress",
225
+ message: `Listening for OpenRouter OAuth callback on ${callback.callbackUrl}`,
226
+ });
227
+ interaction.notify({
228
+ type: "auth_url",
229
+ url: authorizeUrl.toString(),
230
+ instructions: "Complete sign-in in your browser. If the browser is on another machine, paste the final redirect URL here.",
231
+ });
232
+ const manualPromise = interaction
233
+ .prompt({
234
+ type: "manual_code",
235
+ message: "Complete sign-in in your browser, or paste the authorization code / redirect URL here:",
236
+ placeholder: callback.callbackUrl,
237
+ signal: manualAbort.signal,
238
+ })
239
+ .then((input) => {
240
+ manualInput = input;
241
+ callback.cancelWait();
242
+ })
243
+ .catch((error) => {
244
+ manualError = error instanceof Error ? error : new Error(String(error));
245
+ callback.cancelWait();
246
+ });
247
+ const credential = await callback.waitForCredential();
248
+ if (manualError)
249
+ throw manualError;
250
+ if (credential)
251
+ return credential;
252
+ await manualPromise;
253
+ if (manualError)
254
+ throw manualError;
255
+ const code = manualInput ? parseAuthorizationInput(manualInput) : undefined;
256
+ if (!code)
257
+ throw new Error("Missing authorization code");
258
+ interaction.notify({ type: "progress", message: "Exchanging authorization code for an API key..." });
259
+ return await exchangeAuthorizationCode(code, verifier, interaction.signal);
206
260
  }
207
261
  finally {
262
+ manualAbort.abort();
208
263
  callback.close();
209
264
  }
210
265
  }
@@ -1 +1 @@
1
- {"version":3,"file":"openrouter.js","sourceRoot":"","sources":["../../../src/auth/oauth/openrouter.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * OpenRouter OAuth PKCE flow.\n *\n * OpenRouter exchanges an authorization code for a permanent, user-controlled\n * API key rather than an expiring access/refresh token pair. The callback is\n * handled by a one-shot loopback server on an ephemeral port.\n *\n * NOTE: This module uses Node.js http.createServer for the OAuth callback server.\n * It is only intended for CLI use, not browser environments.\n */\n\nimport { createServer, type Server, type ServerResponse } from \"node:http\";\nimport { getProviderEnvValue } from \"../../utils/provider-env.ts\";\nimport type { AuthInteraction, OAuthAuth, OAuthCredential } from \"../types.ts\";\nimport { oauthErrorHtml, oauthSuccessHtml } from \"./oauth-page.ts\";\nimport { generatePKCE } from \"./pkce.ts\";\n\nconst AUTHORIZE_URL = \"https://openrouter.ai/auth\";\nconst TOKEN_URL = \"https://openrouter.ai/api/v1/auth/keys\";\nconst LOGIN_TIMEOUT_MS = 5 * 60 * 1000;\nconst TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;\n\nfunction getCallbackHost(): string {\n\treturn getProviderEnvValue(\"PI_OAUTH_CALLBACK_HOST\") || \"127.0.0.1\";\n}\n\ntype JsonObject = Record<string, unknown>;\n\ntype OpenRouterCallbackServer = {\n\tcallbackUrl: string;\n\tcredential: Promise<OAuthCredential>;\n\tclose(): void;\n};\n\nfunction sendHtml(response: ServerResponse, status: number, html: string): void {\n\tresponse.statusCode = status;\n\tresponse.setHeader(\"content-type\", \"text/html; charset=utf-8\");\n\tresponse.setHeader(\"cache-control\", \"no-store\");\n\tresponse.end(html);\n}\n\nfunction errorDetail(body: JsonObject): string | undefined {\n\tif (typeof body.error_description === \"string\") return body.error_description;\n\tif (typeof body.message === \"string\") return body.message;\n\tif (typeof body.error === \"string\") return body.error;\n\tif (body.error && typeof body.error === \"object\" && !Array.isArray(body.error)) {\n\t\tconst message = (body.error as JsonObject).message;\n\t\tif (typeof message === \"string\") return message;\n\t}\n\treturn undefined;\n}\n\nasync function exchangeAuthorizationCode(\n\tcode: string,\n\tverifier: string,\n\tsignal?: AbortSignal,\n): Promise<OAuthCredential> {\n\tif (signal?.aborted) throw new Error(\"Login cancelled\");\n\tconst controller = new AbortController();\n\tconst onAbort = () => controller.abort(signal?.reason);\n\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\tconst timeout = setTimeout(\n\t\t() => controller.abort(new Error(\"OpenRouter OAuth token exchange timed out\")),\n\t\tTOKEN_EXCHANGE_TIMEOUT_MS,\n\t);\n\n\tlet response: Response;\n\tlet body: JsonObject = {};\n\ttry {\n\t\tresponse = await fetch(TOKEN_URL, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { accept: \"application/json\", \"content-type\": \"application/json\" },\n\t\t\tbody: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: \"S256\" }),\n\t\t\tsignal: controller.signal,\n\t\t});\n\t\ttry {\n\t\t\tconst parsed = (await response.json()) as unknown;\n\t\t\tif (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) body = parsed as JsonObject;\n\t\t} catch {\n\t\t\tif (response.ok) throw new Error(\"OpenRouter OAuth returned invalid JSON\");\n\t\t}\n\t} catch (error) {\n\t\tif (signal?.aborted) throw new Error(\"Login cancelled\");\n\t\tif (controller.signal.aborted) throw new Error(\"OpenRouter OAuth token exchange timed out\");\n\t\tthrow error;\n\t} finally {\n\t\tclearTimeout(timeout);\n\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t}\n\n\tif (!response.ok) {\n\t\tconst detail = errorDetail(body);\n\t\tthrow new Error(`OpenRouter OAuth key exchange failed (HTTP ${response.status})${detail ? `: ${detail}` : \"\"}`);\n\t}\n\n\tif (typeof body.key !== \"string\" || body.key.length === 0) {\n\t\tthrow new Error('OpenRouter OAuth response carries no \"key\"');\n\t}\n\n\treturn {\n\t\ttype: \"oauth\",\n\t\taccess: body.key,\n\t\trefresh: \"\",\n\t\texpires: Number.MAX_SAFE_INTEGER,\n\t};\n}\n\nasync function startCallbackServer(\n\tcallbackPath: string,\n\tverifier: string,\n\tsignal?: AbortSignal,\n): Promise<OpenRouterCallbackServer> {\n\tif (signal?.aborted) throw new Error(\"Login cancelled\");\n\tconst callbackHost = getCallbackHost();\n\tlet resolveCredential: (credential: OAuthCredential) => void = () => {};\n\tlet rejectCredential: (error: Error) => void = () => {};\n\tconst credential = new Promise<OAuthCredential>((resolve, reject) => {\n\t\tresolveCredential = resolve;\n\t\trejectCredential = reject;\n\t});\n\n\tlet server: Server;\n\tlet claimed = false;\n\tlet settled = false;\n\tlet timeout: ReturnType<typeof setTimeout> | undefined;\n\tlet onAbort: (() => void) | undefined;\n\n\tconst finish = (result: { credential: OAuthCredential } | { error: Error }): void => {\n\t\tif (settled) return;\n\t\tsettled = true;\n\t\tif (timeout) clearTimeout(timeout);\n\t\tif (onAbort) signal?.removeEventListener(\"abort\", onAbort);\n\t\tserver.close();\n\t\tif (\"credential\" in result) resolveCredential(result.credential);\n\t\telse rejectCredential(result.error);\n\t};\n\n\tserver = createServer((request, response) => {\n\t\tvoid (async () => {\n\t\t\tconst requestUrl = new URL(request.url ?? \"/\", `http://${callbackHost}`);\n\t\t\tif (request.method !== \"GET\" || requestUrl.pathname !== callbackPath) {\n\t\t\t\tsendHtml(response, 404, oauthErrorHtml(\"OAuth callback route not found.\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (claimed || settled) {\n\t\t\t\tsendHtml(response, 409, oauthErrorHtml(\"This OAuth callback has already been used.\"));\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst oauthError = requestUrl.searchParams.get(\"error\");\n\t\t\tif (oauthError) {\n\t\t\t\tconst description = requestUrl.searchParams.get(\"error_description\") ?? oauthError;\n\t\t\t\tsendHtml(response, 400, oauthErrorHtml(\"OpenRouter authorization was denied.\", description));\n\t\t\t\tfinish({ error: new Error(`OpenRouter authorization failed: ${description}`) });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst code = requestUrl.searchParams.get(\"code\");\n\t\t\tif (!code) {\n\t\t\t\tsendHtml(response, 400, oauthErrorHtml(\"OpenRouter returned no authorization code.\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tclaimed = true;\n\n\t\t\ttry {\n\t\t\t\tconst result = await exchangeAuthorizationCode(code, verifier, signal);\n\t\t\t\tsendHtml(response, 200, oauthSuccessHtml(\"Signed in to OpenRouter. You may now close this page.\"));\n\t\t\t\tfinish({ credential: result });\n\t\t\t} catch (error) {\n\t\t\t\tconst message = error instanceof Error ? error.message : \"Unknown token exchange error\";\n\t\t\t\tsendHtml(response, 502, oauthErrorHtml(\"OpenRouter key exchange failed.\", message));\n\t\t\t\tfinish({ error: error instanceof Error ? error : new Error(message) });\n\t\t\t}\n\t\t})();\n\t});\n\n\tawait new Promise<void>((resolve, reject) => {\n\t\tserver.once(\"error\", reject);\n\t\tserver.listen(0, callbackHost, () => {\n\t\t\tserver.removeListener(\"error\", reject);\n\t\t\tresolve();\n\t\t});\n\t});\n\n\tserver.on(\"error\", (error) => finish({ error }));\n\tonAbort = () => finish({ error: new Error(\"Login cancelled\") });\n\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\tif (signal?.aborted) {\n\t\tsignal.removeEventListener(\"abort\", onAbort);\n\t\tserver.close();\n\t\tthrow new Error(\"Login cancelled\");\n\t}\n\ttimeout = setTimeout(() => finish({ error: new Error(\"OpenRouter OAuth login timed out\") }), LOGIN_TIMEOUT_MS);\n\n\tconst address = server.address();\n\tif (!address || typeof address === \"string\") {\n\t\tfinish({ error: new Error(\"Could not determine the OpenRouter OAuth callback port\") });\n\t\tthrow new Error(\"Could not determine the OpenRouter OAuth callback port\");\n\t}\n\n\treturn {\n\t\tcallbackUrl: `http://${callbackHost}:${address.port}${callbackPath}`,\n\t\tcredential,\n\t\tclose: () => finish({ error: new Error(\"Login cancelled\") }),\n\t};\n}\n\nasync function loginOpenRouter(interaction: AuthInteraction): Promise<OAuthCredential> {\n\tconst { verifier, challenge } = await generatePKCE();\n\tconst callbackPath = `/oauth/callback/${crypto.randomUUID()}`;\n\tconst callback = await startCallbackServer(callbackPath, verifier, interaction.signal);\n\tconst authorizeUrl = new URL(AUTHORIZE_URL);\n\tauthorizeUrl.search = new URLSearchParams({\n\t\tcallback_url: callback.callbackUrl,\n\t\tcode_challenge: challenge,\n\t\tcode_challenge_method: \"S256\",\n\t}).toString();\n\n\tinteraction.notify({\n\t\ttype: \"progress\",\n\t\tmessage: `Listening for OpenRouter OAuth callback on ${callback.callbackUrl}`,\n\t});\n\tinteraction.notify({\n\t\ttype: \"auth_url\",\n\t\turl: authorizeUrl.toString(),\n\t\tinstructions: \"Complete sign-in in your browser.\",\n\t});\n\n\ttry {\n\t\treturn await callback.credential;\n\t} finally {\n\t\tcallback.close();\n\t}\n}\n\nexport const openRouterOAuth: OAuthAuth = {\n\tname: \"OpenRouter OAuth\",\n\tloginLabel: \"Sign in with OpenRouter\",\n\tlogin: loginOpenRouter,\n\tasync refresh(credential) {\n\t\treturn credential;\n\t},\n\tasync toAuth(credential) {\n\t\treturn { apiKey: credential.access };\n\t},\n};\n"]}
1
+ {"version":3,"file":"openrouter.js","sourceRoot":"","sources":["../../../src/auth/oauth/openrouter.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * OpenRouter OAuth PKCE flow.\n *\n * OpenRouter exchanges an authorization code for a permanent, user-controlled\n * API key rather than an expiring access/refresh token pair. The callback is\n * handled by a one-shot loopback server on an ephemeral port, raced against a\n * manual prompt so remote/headless sessions can paste the redirect URL when\n * the browser cannot reach the loopback server.\n *\n * NOTE: This module uses Node.js http.createServer for the OAuth callback server.\n * It is only intended for CLI use, not browser environments.\n */\n\nimport { createServer, type Server, type ServerResponse } from \"node:http\";\nimport { getProviderEnvValue } from \"../../utils/provider-env.ts\";\nimport type { AuthInteraction, OAuthAuth, OAuthCredential } from \"../types.ts\";\nimport { oauthErrorHtml, oauthSuccessHtml } from \"./oauth-page.ts\";\nimport { generatePKCE } from \"./pkce.ts\";\n\nconst AUTHORIZE_URL = \"https://openrouter.ai/auth\";\nconst TOKEN_URL = \"https://openrouter.ai/api/v1/auth/keys\";\nconst LOGIN_TIMEOUT_MS = 5 * 60 * 1000;\nconst TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;\n\nfunction getCallbackHost(): string {\n\treturn getProviderEnvValue(\"PI_OAUTH_CALLBACK_HOST\") || \"127.0.0.1\";\n}\n\ntype JsonObject = Record<string, unknown>;\n\ntype OpenRouterCallbackServer = {\n\tcallbackUrl: string;\n\t/** Stop listening and release timers without settling `waitForCredential`. */\n\tclose: () => void;\n\t/** Hand the login over to manual code entry unless a callback already claimed the exchange. */\n\tcancelWait: () => void;\n\t/**\n\t * Resolves with the credential once a browser callback completes the key\n\t * exchange, or with null once `cancelWait` hands the login over to manual\n\t * code entry. Rejects on timeout, cancellation, or a failed exchange.\n\t */\n\twaitForCredential: () => Promise<OAuthCredential | null>;\n};\n\nfunction sendHtml(response: ServerResponse, status: number, html: string): void {\n\tresponse.statusCode = status;\n\tresponse.setHeader(\"content-type\", \"text/html; charset=utf-8\");\n\tresponse.setHeader(\"cache-control\", \"no-store\");\n\tresponse.end(html);\n}\n\nfunction parseAuthorizationInput(input: string): string | undefined {\n\tconst value = input.trim();\n\tif (!value) return undefined;\n\n\ttry {\n\t\treturn new URL(value).searchParams.get(\"code\") ?? undefined;\n\t} catch {\n\t\t// not a URL\n\t}\n\n\tif (value.includes(\"code=\")) {\n\t\treturn new URLSearchParams(value).get(\"code\") ?? undefined;\n\t}\n\n\treturn value;\n}\n\nfunction errorDetail(body: JsonObject): string | undefined {\n\tif (typeof body.error_description === \"string\") return body.error_description;\n\tif (typeof body.message === \"string\") return body.message;\n\tif (typeof body.error === \"string\") return body.error;\n\tif (body.error && typeof body.error === \"object\" && !Array.isArray(body.error)) {\n\t\tconst message = (body.error as JsonObject).message;\n\t\tif (typeof message === \"string\") return message;\n\t}\n\treturn undefined;\n}\n\nasync function exchangeAuthorizationCode(\n\tcode: string,\n\tverifier: string,\n\tsignal?: AbortSignal,\n): Promise<OAuthCredential> {\n\tif (signal?.aborted) throw new Error(\"Login cancelled\");\n\tconst controller = new AbortController();\n\tconst onAbort = () => controller.abort(signal?.reason);\n\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\tconst timeout = setTimeout(\n\t\t() => controller.abort(new Error(\"OpenRouter OAuth token exchange timed out\")),\n\t\tTOKEN_EXCHANGE_TIMEOUT_MS,\n\t);\n\n\tlet response: Response;\n\tlet body: JsonObject = {};\n\ttry {\n\t\tresponse = await fetch(TOKEN_URL, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { accept: \"application/json\", \"content-type\": \"application/json\" },\n\t\t\tbody: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: \"S256\" }),\n\t\t\tsignal: controller.signal,\n\t\t});\n\t\ttry {\n\t\t\tconst parsed = (await response.json()) as unknown;\n\t\t\tif (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) body = parsed as JsonObject;\n\t\t} catch {\n\t\t\tif (response.ok) throw new Error(\"OpenRouter OAuth returned invalid JSON\");\n\t\t}\n\t} catch (error) {\n\t\tif (signal?.aborted) throw new Error(\"Login cancelled\");\n\t\tif (controller.signal.aborted) throw new Error(\"OpenRouter OAuth token exchange timed out\");\n\t\tthrow error;\n\t} finally {\n\t\tclearTimeout(timeout);\n\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t}\n\n\tif (!response.ok) {\n\t\tconst detail = errorDetail(body);\n\t\tthrow new Error(`OpenRouter OAuth key exchange failed (HTTP ${response.status})${detail ? `: ${detail}` : \"\"}`);\n\t}\n\n\tif (typeof body.key !== \"string\" || body.key.length === 0) {\n\t\tthrow new Error('OpenRouter OAuth response carries no \"key\"');\n\t}\n\n\treturn {\n\t\ttype: \"oauth\",\n\t\taccess: body.key,\n\t\trefresh: \"\",\n\t\texpires: Number.MAX_SAFE_INTEGER,\n\t};\n}\n\nasync function startCallbackServer(\n\tcallbackPath: string,\n\tverifier: string,\n\tsignal?: AbortSignal,\n): Promise<OpenRouterCallbackServer> {\n\tif (signal?.aborted) throw new Error(\"Login cancelled\");\n\tconst callbackHost = getCallbackHost();\n\tlet resolveCredential: (credential: OAuthCredential | null) => void = () => {};\n\tlet rejectCredential: (error: Error) => void = () => {};\n\tconst credential = new Promise<OAuthCredential | null>((resolve, reject) => {\n\t\tresolveCredential = resolve;\n\t\trejectCredential = reject;\n\t});\n\n\tlet server: Server;\n\tlet claimed = false;\n\tlet settled = false;\n\tlet timeout: ReturnType<typeof setTimeout> | undefined;\n\tlet onAbort: (() => void) | undefined;\n\n\tconst close = (): void => {\n\t\tif (timeout) clearTimeout(timeout);\n\t\tif (onAbort) signal?.removeEventListener(\"abort\", onAbort);\n\t\tserver.close();\n\t};\n\n\tconst finish = (result: { credential: OAuthCredential | null } | { error: Error }): void => {\n\t\tif (settled) return;\n\t\tsettled = true;\n\t\tclose();\n\t\tif (\"credential\" in result) resolveCredential(result.credential);\n\t\telse rejectCredential(result.error);\n\t};\n\n\tserver = createServer((request, response) => {\n\t\tvoid (async () => {\n\t\t\tconst requestUrl = new URL(request.url ?? \"/\", `http://${callbackHost}`);\n\t\t\tif (request.method !== \"GET\" || requestUrl.pathname !== callbackPath) {\n\t\t\t\tsendHtml(response, 404, oauthErrorHtml(\"OAuth callback route not found.\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (claimed || settled) {\n\t\t\t\tsendHtml(response, 409, oauthErrorHtml(\"This OAuth callback has already been used.\"));\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst oauthError = requestUrl.searchParams.get(\"error\");\n\t\t\tif (oauthError) {\n\t\t\t\tconst description = requestUrl.searchParams.get(\"error_description\") ?? oauthError;\n\t\t\t\tsendHtml(response, 400, oauthErrorHtml(\"OpenRouter authorization was denied.\", description));\n\t\t\t\tfinish({ error: new Error(`OpenRouter authorization failed: ${description}`) });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst code = requestUrl.searchParams.get(\"code\");\n\t\t\tif (!code) {\n\t\t\t\tsendHtml(response, 400, oauthErrorHtml(\"OpenRouter returned no authorization code.\"));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tclaimed = true;\n\n\t\t\ttry {\n\t\t\t\tconst result = await exchangeAuthorizationCode(code, verifier, signal);\n\t\t\t\tsendHtml(response, 200, oauthSuccessHtml(\"Signed in to OpenRouter. You may now close this page.\"));\n\t\t\t\tfinish({ credential: result });\n\t\t\t} catch (error) {\n\t\t\t\tconst message = error instanceof Error ? error.message : \"Unknown token exchange error\";\n\t\t\t\tsendHtml(response, 502, oauthErrorHtml(\"OpenRouter key exchange failed.\", message));\n\t\t\t\tfinish({ error: error instanceof Error ? error : new Error(message) });\n\t\t\t}\n\t\t})();\n\t});\n\n\tawait new Promise<void>((resolve, reject) => {\n\t\tserver.once(\"error\", reject);\n\t\tserver.listen(0, callbackHost, () => {\n\t\t\tserver.removeListener(\"error\", reject);\n\t\t\tresolve();\n\t\t});\n\t});\n\n\tserver.on(\"error\", (error) => finish({ error }));\n\tonAbort = () => finish({ error: new Error(\"Login cancelled\") });\n\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\tif (signal?.aborted) {\n\t\tclose();\n\t\tthrow new Error(\"Login cancelled\");\n\t}\n\ttimeout = setTimeout(() => finish({ error: new Error(\"OpenRouter OAuth login timed out\") }), LOGIN_TIMEOUT_MS);\n\n\tconst address = server.address();\n\tif (!address || typeof address === \"string\") {\n\t\tclose();\n\t\tthrow new Error(\"Could not determine the OpenRouter OAuth callback port\");\n\t}\n\n\treturn {\n\t\tcallbackUrl: `http://${callbackHost}:${address.port}${callbackPath}`,\n\t\tclose,\n\t\t// A claimed callback is already exchanging its code; let that exchange settle the login.\n\t\tcancelWait: () => {\n\t\t\tif (!claimed) finish({ credential: null });\n\t\t},\n\t\twaitForCredential: () => credential,\n\t};\n}\n\nasync function loginOpenRouter(interaction: AuthInteraction): Promise<OAuthCredential> {\n\tconst { verifier, challenge } = await generatePKCE();\n\tconst callbackPath = `/oauth/callback/${crypto.randomUUID()}`;\n\tconst callback = await startCallbackServer(callbackPath, verifier, interaction.signal);\n\tconst manualAbort = new AbortController();\n\tlet manualInput: string | undefined;\n\tlet manualError: Error | undefined;\n\n\ttry {\n\t\tconst authorizeUrl = new URL(AUTHORIZE_URL);\n\t\tauthorizeUrl.search = new URLSearchParams({\n\t\t\tcallback_url: callback.callbackUrl,\n\t\t\tcode_challenge: challenge,\n\t\t\tcode_challenge_method: \"S256\",\n\t\t}).toString();\n\n\t\tinteraction.notify({\n\t\t\ttype: \"progress\",\n\t\t\tmessage: `Listening for OpenRouter OAuth callback on ${callback.callbackUrl}`,\n\t\t});\n\t\tinteraction.notify({\n\t\t\ttype: \"auth_url\",\n\t\t\turl: authorizeUrl.toString(),\n\t\t\tinstructions:\n\t\t\t\t\"Complete sign-in in your browser. If the browser is on another machine, paste the final redirect URL here.\",\n\t\t});\n\n\t\tconst manualPromise = interaction\n\t\t\t.prompt({\n\t\t\t\ttype: \"manual_code\",\n\t\t\t\tmessage: \"Complete sign-in in your browser, or paste the authorization code / redirect URL here:\",\n\t\t\t\tplaceholder: callback.callbackUrl,\n\t\t\t\tsignal: manualAbort.signal,\n\t\t\t})\n\t\t\t.then((input) => {\n\t\t\t\tmanualInput = input;\n\t\t\t\tcallback.cancelWait();\n\t\t\t})\n\t\t\t.catch((error) => {\n\t\t\t\tmanualError = error instanceof Error ? error : new Error(String(error));\n\t\t\t\tcallback.cancelWait();\n\t\t\t});\n\n\t\tconst credential = await callback.waitForCredential();\n\t\tif (manualError) throw manualError;\n\t\tif (credential) return credential;\n\n\t\tawait manualPromise;\n\t\tif (manualError) throw manualError;\n\t\tconst code = manualInput ? parseAuthorizationInput(manualInput) : undefined;\n\t\tif (!code) throw new Error(\"Missing authorization code\");\n\t\tinteraction.notify({ type: \"progress\", message: \"Exchanging authorization code for an API key...\" });\n\t\treturn await exchangeAuthorizationCode(code, verifier, interaction.signal);\n\t} finally {\n\t\tmanualAbort.abort();\n\t\tcallback.close();\n\t}\n}\n\nexport const openRouterOAuth: OAuthAuth = {\n\tname: \"OpenRouter OAuth\",\n\tloginLabel: \"Sign in with OpenRouter\",\n\tlogin: loginOpenRouter,\n\tasync refresh(credential) {\n\t\treturn credential;\n\t},\n\tasync toAuth(credential) {\n\t\treturn { apiKey: credential.access };\n\t},\n};\n"]}
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Radius gateway OAuth flow.
3
3
  *
4
- * Radius is a pi-messages gateway. OAuth endpoints are discovered from the
5
- * gateway (`/v1/oauth`); model catalog loading is owned by the Radius provider.
4
+ * Radius is a pi-messages gateway. OAuth client APIs live on the configured
5
+ * gateway; only the interactive browser authorization endpoint is discovered.
6
+ * Model catalog loading is owned by the Radius provider.
6
7
  *
7
8
  * NOTE: This module uses node:http for the OAuth callback server.
8
9
  * It is only intended for CLI use, not browser environments.
@@ -1 +1 @@
1
- {"version":3,"file":"radius.d.ts","sourceRoot":"","sources":["../../../src/auth/oauth/radius.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAWH,OAAO,KAAK,EAAmB,SAAS,EAAmB,MAAM,aAAa,CAAC;AA+U/E,MAAM,WAAW,kBAAkB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,SAAS,CAkDxE","sourcesContent":["/**\n * Radius gateway OAuth flow.\n *\n * Radius is a pi-messages gateway. OAuth endpoints are discovered from the\n * gateway (`/v1/oauth`); model catalog loading is owned by the Radius provider.\n *\n * NOTE: This module uses node:http for the OAuth callback server.\n * It is only intended for CLI use, not browser environments.\n */\n\n// NEVER convert to top-level imports - breaks browser/Vite builds\nlet _http: typeof import(\"node:http\") | null = null;\nif (typeof process !== \"undefined\" && (process.versions?.node || process.versions?.bun)) {\n\timport(\"node:http\").then((m) => {\n\t\t_http = m;\n\t});\n}\n\nimport { normalizeRadiusGatewayUrl } from \"../../providers/radius-config.ts\";\nimport type { AuthInteraction, OAuthAuth, OAuthCredential } from \"../types.ts\";\nimport { pollOAuthDeviceCodeFlow } from \"./device-code.ts\";\nimport { oauthErrorHtml, oauthSuccessHtml } from \"./oauth-page.ts\";\nimport { generatePKCE } from \"./pkce.ts\";\n\nconst CALLBACK_HOST = \"127.0.0.1\";\nconst CALLBACK_PORT = 1456;\nconst CALLBACK_PATH = \"/oauth/callback\";\nconst REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`;\nconst TOKEN_EXPIRY_SKEW_MS = 60_000;\nconst LOGIN_METHOD_BROWSER = \"browser\";\nconst LOGIN_METHOD_DEVICE_CODE = \"device-code\";\n\ntype RadiusOAuthConfig = {\n\tissuer: string;\n\tauthorizationEndpoint: string;\n\ttokenEndpoint: string;\n\tdeviceAuthorizationEndpoint: string;\n\tdeviceAuthorizationEventsEndpoint: string;\n\tverificationEndpoint: string;\n\tclientId: string;\n\tscope: string;\n\tdeviceCodeGrantType: string;\n};\n\ntype DeviceAuthorizationResponse = {\n\tdevice_code: string;\n\tuser_code: string;\n\tverification_uri?: string;\n\tverification_uri_complete?: string;\n\texpires_in: number;\n\tinterval?: number;\n};\n\nasync function loadRadiusOAuthConfig(gateway: string): Promise<RadiusOAuthConfig> {\n\tconst response = await fetch(new URL(\"/v1/oauth\", gateway), {\n\t\theaders: { accept: \"application/json\" },\n\t});\n\n\tif (!response.ok) {\n\t\tthrow new Error(\n\t\t\t`Could not load Radius OAuth config from ${gateway}: ${response.status} ${await response.text()}`,\n\t\t);\n\t}\n\n\treturn (await response.json()) as RadiusOAuthConfig;\n}\n\nclass OAuthResponseError extends Error {\n\treadonly status: number;\n\treadonly oauthError?: string;\n\n\tconstructor(status: number, oauthError: string | undefined, description: string | undefined, message: string) {\n\t\tconst detail = oauthError\n\t\t\t? description\n\t\t\t\t? `${oauthError}: ${description}`\n\t\t\t\t: oauthError\n\t\t\t: description || String(status);\n\t\tsuper(`${message}: ${detail}`);\n\t\tthis.status = status;\n\t\tthis.oauthError = oauthError;\n\t}\n}\n\nasync function readOAuthResponseError(response: Response, message: string): Promise<OAuthResponseError> {\n\tconst text = await response.text().catch(() => \"\");\n\tlet oauthError: string | undefined;\n\tlet description: string | undefined;\n\n\tif (text) {\n\t\ttry {\n\t\t\tconst data = JSON.parse(text) as { error?: unknown; error_description?: unknown };\n\t\t\toauthError = typeof data.error === \"string\" ? data.error : undefined;\n\t\t\tdescription = typeof data.error_description === \"string\" ? data.error_description : undefined;\n\t\t} catch {\n\t\t\tdescription = text;\n\t\t}\n\t}\n\n\treturn new OAuthResponseError(response.status, oauthError, description, message);\n}\n\nasync function requestOAuthToken(\n\toauth: RadiusOAuthConfig,\n\tbody: URLSearchParams,\n\tsignal?: AbortSignal,\n): Promise<OAuthCredential> {\n\tlet response: Response;\n\ttry {\n\t\tresponse = await fetch(oauth.tokenEndpoint, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { accept: \"application/json\", \"content-type\": \"application/x-www-form-urlencoded\" },\n\t\t\tbody,\n\t\t\tsignal,\n\t\t});\n\t} catch (error) {\n\t\tif (signal?.aborted) {\n\t\t\tthrow new Error(\"Login cancelled\");\n\t\t}\n\t\tthrow error;\n\t}\n\n\tif (!response.ok) {\n\t\tthrow await readOAuthResponseError(response, \"Radius OAuth token request failed\");\n\t}\n\n\tconst data = (await response.json()) as {\n\t\taccess_token: string;\n\t\trefresh_token: string;\n\t\texpires_in: number;\n\t\tscope?: string;\n\t};\n\n\treturn {\n\t\ttype: \"oauth\",\n\t\taccess: data.access_token,\n\t\trefresh: data.refresh_token,\n\t\texpires: Date.now() + data.expires_in * 1000 - TOKEN_EXPIRY_SKEW_MS,\n\t\tscope: data.scope,\n\t};\n}\n\ntype OAuthCallbackServer = {\n\twaitForCode(): Promise<string | null>;\n\tclose(): void;\n};\n\nfunction startOAuthCallbackServer(\n\texpectedState: string,\n\tsignal: AbortSignal | undefined,\n): Promise<OAuthCallbackServer> {\n\tif (!_http) {\n\t\tthrow new Error(\"Radius OAuth is only available in Node.js environments\");\n\t}\n\n\tlet settle: (code: string | null) => void = () => {};\n\tlet settled = false;\n\tconst wait = new Promise<string | null>((resolve) => {\n\t\tsettle = resolve;\n\t});\n\tconst finish = (code: string | null) => {\n\t\tif (settled) {\n\t\t\treturn;\n\t\t}\n\t\tsettled = true;\n\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\tsettle(code);\n\t};\n\tconst onAbort = () => finish(null);\n\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\n\tconst sendPage = (response: import(\"node:http\").ServerResponse, status: number, html: string) => {\n\t\tresponse.statusCode = status;\n\t\tresponse.setHeader(\"content-type\", \"text/html; charset=utf-8\");\n\t\tresponse.end(html);\n\t};\n\n\tconst server = _http.createServer((request, response) => {\n\t\tconst url = new URL(request.url ?? \"/\", REDIRECT_URI);\n\t\tif (url.pathname !== CALLBACK_PATH) {\n\t\t\tsendPage(response, 404, oauthErrorHtml(\"Callback route not found.\"));\n\t\t\treturn;\n\t\t}\n\t\tif (url.searchParams.get(\"state\") !== expectedState) {\n\t\t\tsendPage(response, 400, oauthErrorHtml(\"OAuth state mismatch.\"));\n\t\t\treturn;\n\t\t}\n\n\t\tconst error = url.searchParams.get(\"error\");\n\t\tif (error) {\n\t\t\tsendPage(response, 400, oauthErrorHtml(url.searchParams.get(\"error_description\") ?? error));\n\t\t\tfinish(null);\n\t\t\treturn;\n\t\t}\n\n\t\tconst code = url.searchParams.get(\"code\");\n\t\tif (!code) {\n\t\t\tsendPage(response, 400, oauthErrorHtml(\"Missing authorization code.\"));\n\t\t\treturn;\n\t\t}\n\n\t\tsendPage(response, 200, oauthSuccessHtml(\"Signed in to Radius. You may now close this page.\"));\n\t\tfinish(code);\n\t});\n\n\treturn new Promise((resolve) => {\n\t\tserver\n\t\t\t.listen(CALLBACK_PORT, CALLBACK_HOST, () => {\n\t\t\t\tresolve({\n\t\t\t\t\twaitForCode: () => wait,\n\t\t\t\t\tclose: () => {\n\t\t\t\t\t\tfinish(null);\n\t\t\t\t\t\tserver.close();\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t})\n\t\t\t.once(\"error\", () => {\n\t\t\t\tfinish(null);\n\t\t\t\tresolve({ waitForCode: async () => null, close: () => {} });\n\t\t\t});\n\t});\n}\n\nasync function loginWithBrowser(oauth: RadiusOAuthConfig, interaction: AuthInteraction): Promise<OAuthCredential> {\n\tconst { verifier, challenge } = await generatePKCE();\n\tconst state = crypto.randomUUID();\n\tconst authorizeUrl = new URL(oauth.authorizationEndpoint);\n\tauthorizeUrl.search = new URLSearchParams({\n\t\tresponse_type: \"code\",\n\t\tclient_id: oauth.clientId,\n\t\tredirect_uri: REDIRECT_URI,\n\t\tscope: oauth.scope,\n\t\tcode_challenge: challenge,\n\t\tcode_challenge_method: \"S256\",\n\t\thandoff: \"url\",\n\t\tstate,\n\t}).toString();\n\n\tconst callbackServer = await startOAuthCallbackServer(state, interaction.signal);\n\tinteraction.notify({ type: \"progress\", message: `Listening for OAuth callback on ${REDIRECT_URI}` });\n\tinteraction.notify({\n\t\ttype: \"auth_url\",\n\t\turl: authorizeUrl.toString(),\n\t\tinstructions: \"Continue in your browser.\",\n\t});\n\n\ttry {\n\t\tconst code = await callbackServer.waitForCode();\n\t\tif (!code) {\n\t\t\tif (interaction.signal?.aborted) {\n\t\t\t\tthrow new Error(\"Login cancelled\");\n\t\t\t}\n\t\t\tthrow new Error(\"OAuth callback did not complete.\");\n\t\t}\n\t\treturn await requestOAuthToken(\n\t\t\toauth,\n\t\t\tnew URLSearchParams({\n\t\t\t\tgrant_type: \"authorization_code\",\n\t\t\t\tclient_id: oauth.clientId,\n\t\t\t\tredirect_uri: REDIRECT_URI,\n\t\t\t\tcode,\n\t\t\t\tcode_verifier: verifier,\n\t\t\t}),\n\t\t\tinteraction.signal,\n\t\t);\n\t} finally {\n\t\tcallbackServer.close();\n\t}\n}\n\nasync function requestDeviceAuthorization(\n\toauth: RadiusOAuthConfig,\n\tsignal: AbortSignal | undefined,\n): Promise<DeviceAuthorizationResponse> {\n\tlet response: Response;\n\ttry {\n\t\tresponse = await fetch(oauth.deviceAuthorizationEndpoint, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { accept: \"application/json\", \"content-type\": \"application/x-www-form-urlencoded\" },\n\t\t\tbody: new URLSearchParams({ client_id: oauth.clientId, scope: oauth.scope }),\n\t\t\tsignal,\n\t\t});\n\t} catch (error) {\n\t\tif (signal?.aborted) {\n\t\t\tthrow new Error(\"Login cancelled\");\n\t\t}\n\t\tthrow error;\n\t}\n\n\tif (!response.ok) {\n\t\tthrow await readOAuthResponseError(response, \"Radius OAuth device authorization failed\");\n\t}\n\n\tconst data = (await response.json()) as Partial<DeviceAuthorizationResponse>;\n\tif (!data.device_code || !data.user_code || !data.expires_in) {\n\t\tthrow new Error(\"Radius OAuth device authorization response is missing required fields\");\n\t}\n\n\treturn {\n\t\tdevice_code: data.device_code,\n\t\tuser_code: data.user_code,\n\t\tverification_uri: data.verification_uri,\n\t\tverification_uri_complete: data.verification_uri_complete,\n\t\texpires_in: data.expires_in,\n\t\tinterval: data.interval,\n\t};\n}\n\nasync function loginWithDeviceCode(oauth: RadiusOAuthConfig, interaction: AuthInteraction): Promise<OAuthCredential> {\n\tconst device = await requestDeviceAuthorization(oauth, interaction.signal);\n\tinteraction.notify({\n\t\ttype: \"device_code\",\n\t\tuserCode: device.user_code,\n\t\tverificationUri: device.verification_uri || oauth.verificationEndpoint,\n\t\tintervalSeconds: device.interval,\n\t\texpiresInSeconds: device.expires_in,\n\t});\n\n\treturn pollOAuthDeviceCodeFlow<OAuthCredential>({\n\t\tintervalSeconds: device.interval,\n\t\texpiresInSeconds: device.expires_in,\n\t\tsignal: interaction.signal,\n\t\tpoll: async () => {\n\t\t\ttry {\n\t\t\t\tconst credentials = await requestOAuthToken(\n\t\t\t\t\toauth,\n\t\t\t\t\tnew URLSearchParams({\n\t\t\t\t\t\tgrant_type: oauth.deviceCodeGrantType,\n\t\t\t\t\t\tclient_id: oauth.clientId,\n\t\t\t\t\t\tdevice_code: device.device_code,\n\t\t\t\t\t}),\n\t\t\t\t\tinteraction.signal,\n\t\t\t\t);\n\t\t\t\treturn { status: \"complete\", value: credentials };\n\t\t\t} catch (error) {\n\t\t\t\tif (!(error instanceof OAuthResponseError)) {\n\t\t\t\t\tthrow error;\n\t\t\t\t}\n\t\t\t\tswitch (error.oauthError) {\n\t\t\t\t\tcase \"authorization_pending\":\n\t\t\t\t\t\treturn { status: \"pending\" };\n\t\t\t\t\tcase \"slow_down\":\n\t\t\t\t\t\treturn { status: \"slow_down\" };\n\t\t\t\t\tcase \"expired_token\":\n\t\t\t\t\t\treturn { status: \"failed\", message: \"Device authorization expired.\" };\n\t\t\t\t\tcase \"access_denied\":\n\t\t\t\t\t\treturn { status: \"failed\", message: \"Device authorization was denied.\" };\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tthrow error;\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t});\n}\n\nexport interface RadiusOAuthOptions {\n\tname: string;\n\tgateway: string;\n}\n\nexport function createRadiusOAuth(options: RadiusOAuthOptions): OAuthAuth {\n\tconst gateway = normalizeRadiusGatewayUrl(options.gateway);\n\n\treturn {\n\t\tname: options.name,\n\n\t\tasync login(interaction): Promise<OAuthCredential> {\n\t\t\tconst oauth = await loadRadiusOAuthConfig(gateway);\n\t\t\tconst loginMethod = await interaction.prompt({\n\t\t\t\ttype: \"select\",\n\t\t\t\tmessage: `Sign in to ${options.name}:`,\n\t\t\t\toptions: [\n\t\t\t\t\t{ id: LOGIN_METHOD_BROWSER, label: \"Sign in with browser (recommended)\" },\n\t\t\t\t\t{\n\t\t\t\t\t\tid: LOGIN_METHOD_DEVICE_CODE,\n\t\t\t\t\t\tlabel: \"Sign in with device code (when signing in from another device)\",\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tlet credential: OAuthCredential;\n\t\t\tif (loginMethod === LOGIN_METHOD_DEVICE_CODE) {\n\t\t\t\tcredential = await loginWithDeviceCode(oauth, interaction);\n\t\t\t} else if (loginMethod === LOGIN_METHOD_BROWSER) {\n\t\t\t\tcredential = await loginWithBrowser(oauth, interaction);\n\t\t\t} else {\n\t\t\t\tthrow new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`);\n\t\t\t}\n\n\t\t\treturn credential;\n\t\t},\n\n\t\tasync refresh(credential, signal): Promise<OAuthCredential> {\n\t\t\tconst oauth = await loadRadiusOAuthConfig(gateway);\n\t\t\tconst refreshed = await requestOAuthToken(\n\t\t\t\toauth,\n\t\t\t\tnew URLSearchParams({\n\t\t\t\t\tgrant_type: \"refresh_token\",\n\t\t\t\t\tclient_id: oauth.clientId,\n\t\t\t\t\trefresh_token: credential.refresh,\n\t\t\t\t}),\n\t\t\t\tsignal,\n\t\t\t);\n\t\t\treturn refreshed;\n\t\t},\n\n\t\tasync toAuth(credential) {\n\t\t\treturn { apiKey: credential.access };\n\t\t},\n\t};\n}\n"]}
1
+ {"version":3,"file":"radius.d.ts","sourceRoot":"","sources":["../../../src/auth/oauth/radius.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAWH,OAAO,KAAK,EAAmB,SAAS,EAAmB,MAAM,aAAa,CAAC;AAgV/E,MAAM,WAAW,kBAAkB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,SAAS,CA8CxE","sourcesContent":["/**\n * Radius gateway OAuth flow.\n *\n * Radius is a pi-messages gateway. OAuth client APIs live on the configured\n * gateway; only the interactive browser authorization endpoint is discovered.\n * Model catalog loading is owned by the Radius provider.\n *\n * NOTE: This module uses node:http for the OAuth callback server.\n * It is only intended for CLI use, not browser environments.\n */\n\n// NEVER convert to top-level imports - breaks browser/Vite builds\nlet _http: typeof import(\"node:http\") | null = null;\nif (typeof process !== \"undefined\" && (process.versions?.node || process.versions?.bun)) {\n\timport(\"node:http\").then((m) => {\n\t\t_http = m;\n\t});\n}\n\nimport { normalizeRadiusGatewayUrl } from \"../../providers/radius-config.ts\";\nimport type { AuthInteraction, OAuthAuth, OAuthCredential } from \"../types.ts\";\nimport { pollOAuthDeviceCodeFlow } from \"./device-code.ts\";\nimport { oauthErrorHtml, oauthSuccessHtml } from \"./oauth-page.ts\";\nimport { generatePKCE } from \"./pkce.ts\";\n\nconst CALLBACK_HOST = \"127.0.0.1\";\nconst CALLBACK_PORT = 1456;\nconst CALLBACK_PATH = \"/oauth/callback\";\nconst REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`;\nconst TOKEN_EXPIRY_SKEW_MS = 60_000;\nconst LOGIN_METHOD_BROWSER = \"browser\";\nconst LOGIN_METHOD_DEVICE_CODE = \"device-code\";\nconst OAUTH_CLIENT_ID = \"pi-gateway\";\nconst OAUTH_SCOPE = \"gateway offline_access\";\nconst OAUTH_DEVICE_CODE_GRANT_TYPE = \"urn:ietf:params:oauth:grant-type:device_code\";\n\ntype RadiusOAuthDiscovery = {\n\tauthorizationEndpoint: string;\n};\n\ntype DeviceAuthorizationResponse = {\n\tdevice_code: string;\n\tuser_code: string;\n\tverification_uri: string;\n\texpires_in: number;\n\tinterval?: number;\n};\n\nasync function loadRadiusOAuthDiscovery(gateway: string): Promise<RadiusOAuthDiscovery> {\n\tconst response = await fetch(new URL(\"/v1/oauth\", gateway), {\n\t\theaders: { accept: \"application/json\" },\n\t});\n\n\tif (!response.ok) {\n\t\tthrow new Error(\n\t\t\t`Could not load Radius OAuth config from ${gateway}: ${response.status} ${await response.text()}`,\n\t\t);\n\t}\n\n\tconst discovery = (await response.json()) as Partial<RadiusOAuthDiscovery>;\n\tif (typeof discovery.authorizationEndpoint !== \"string\") {\n\t\tthrow new Error(`Invalid Radius OAuth config from ${gateway}`);\n\t}\n\treturn { authorizationEndpoint: discovery.authorizationEndpoint };\n}\n\nclass OAuthResponseError extends Error {\n\treadonly status: number;\n\treadonly oauthError?: string;\n\n\tconstructor(status: number, oauthError: string | undefined, description: string | undefined, message: string) {\n\t\tconst detail = oauthError\n\t\t\t? description\n\t\t\t\t? `${oauthError}: ${description}`\n\t\t\t\t: oauthError\n\t\t\t: description || String(status);\n\t\tsuper(`${message}: ${detail}`);\n\t\tthis.status = status;\n\t\tthis.oauthError = oauthError;\n\t}\n}\n\nasync function readOAuthResponseError(response: Response, message: string): Promise<OAuthResponseError> {\n\tconst text = await response.text().catch(() => \"\");\n\tlet oauthError: string | undefined;\n\tlet description: string | undefined;\n\n\tif (text) {\n\t\ttry {\n\t\t\tconst data = JSON.parse(text) as { error?: unknown; error_description?: unknown };\n\t\t\toauthError = typeof data.error === \"string\" ? data.error : undefined;\n\t\t\tdescription = typeof data.error_description === \"string\" ? data.error_description : undefined;\n\t\t} catch {\n\t\t\tdescription = text;\n\t\t}\n\t}\n\n\treturn new OAuthResponseError(response.status, oauthError, description, message);\n}\n\nasync function requestOAuthToken(\n\tgateway: string,\n\tbody: URLSearchParams,\n\tsignal?: AbortSignal,\n): Promise<OAuthCredential> {\n\tlet response: Response;\n\ttry {\n\t\tresponse = await fetch(new URL(\"/v1/oauth/token\", gateway), {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { accept: \"application/json\", \"content-type\": \"application/x-www-form-urlencoded\" },\n\t\t\tbody,\n\t\t\tsignal,\n\t\t});\n\t} catch (error) {\n\t\tif (signal?.aborted) {\n\t\t\tthrow new Error(\"Login cancelled\");\n\t\t}\n\t\tthrow error;\n\t}\n\n\tif (!response.ok) {\n\t\tthrow await readOAuthResponseError(response, \"Radius OAuth token request failed\");\n\t}\n\n\tconst data = (await response.json()) as {\n\t\taccess_token: string;\n\t\trefresh_token: string;\n\t\texpires_in: number;\n\t\tscope?: string;\n\t};\n\n\treturn {\n\t\ttype: \"oauth\",\n\t\taccess: data.access_token,\n\t\trefresh: data.refresh_token,\n\t\texpires: Date.now() + data.expires_in * 1000 - TOKEN_EXPIRY_SKEW_MS,\n\t\tscope: data.scope,\n\t};\n}\n\ntype OAuthCallbackServer = {\n\twaitForCode(): Promise<string | null>;\n\tclose(): void;\n};\n\nfunction startOAuthCallbackServer(\n\texpectedState: string,\n\tsignal: AbortSignal | undefined,\n): Promise<OAuthCallbackServer> {\n\tif (!_http) {\n\t\tthrow new Error(\"Radius OAuth is only available in Node.js environments\");\n\t}\n\n\tlet settle: (code: string | null) => void = () => {};\n\tlet settled = false;\n\tconst wait = new Promise<string | null>((resolve) => {\n\t\tsettle = resolve;\n\t});\n\tconst finish = (code: string | null) => {\n\t\tif (settled) {\n\t\t\treturn;\n\t\t}\n\t\tsettled = true;\n\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\tsettle(code);\n\t};\n\tconst onAbort = () => finish(null);\n\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\n\tconst sendPage = (response: import(\"node:http\").ServerResponse, status: number, html: string) => {\n\t\tresponse.statusCode = status;\n\t\tresponse.setHeader(\"content-type\", \"text/html; charset=utf-8\");\n\t\tresponse.end(html);\n\t};\n\n\tconst server = _http.createServer((request, response) => {\n\t\tconst url = new URL(request.url ?? \"/\", REDIRECT_URI);\n\t\tif (url.pathname !== CALLBACK_PATH) {\n\t\t\tsendPage(response, 404, oauthErrorHtml(\"Callback route not found.\"));\n\t\t\treturn;\n\t\t}\n\t\tif (url.searchParams.get(\"state\") !== expectedState) {\n\t\t\tsendPage(response, 400, oauthErrorHtml(\"OAuth state mismatch.\"));\n\t\t\treturn;\n\t\t}\n\n\t\tconst error = url.searchParams.get(\"error\");\n\t\tif (error) {\n\t\t\tsendPage(response, 400, oauthErrorHtml(url.searchParams.get(\"error_description\") ?? error));\n\t\t\tfinish(null);\n\t\t\treturn;\n\t\t}\n\n\t\tconst code = url.searchParams.get(\"code\");\n\t\tif (!code) {\n\t\t\tsendPage(response, 400, oauthErrorHtml(\"Missing authorization code.\"));\n\t\t\treturn;\n\t\t}\n\n\t\tsendPage(response, 200, oauthSuccessHtml(\"Signed in to Radius. You may now close this page.\"));\n\t\tfinish(code);\n\t});\n\n\treturn new Promise((resolve) => {\n\t\tserver\n\t\t\t.listen(CALLBACK_PORT, CALLBACK_HOST, () => {\n\t\t\t\tresolve({\n\t\t\t\t\twaitForCode: () => wait,\n\t\t\t\t\tclose: () => {\n\t\t\t\t\t\tfinish(null);\n\t\t\t\t\t\tserver.close();\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t})\n\t\t\t.once(\"error\", () => {\n\t\t\t\tfinish(null);\n\t\t\t\tresolve({ waitForCode: async () => null, close: () => {} });\n\t\t\t});\n\t});\n}\n\nasync function loginWithBrowser(\n\tgateway: string,\n\tauthorizationEndpoint: string,\n\tinteraction: AuthInteraction,\n): Promise<OAuthCredential> {\n\tconst { verifier, challenge } = await generatePKCE();\n\tconst state = crypto.randomUUID();\n\tconst authorizeUrl = new URL(authorizationEndpoint);\n\tauthorizeUrl.search = new URLSearchParams({\n\t\tresponse_type: \"code\",\n\t\tclient_id: OAUTH_CLIENT_ID,\n\t\tredirect_uri: REDIRECT_URI,\n\t\tscope: OAUTH_SCOPE,\n\t\tcode_challenge: challenge,\n\t\tcode_challenge_method: \"S256\",\n\t\thandoff: \"url\",\n\t\tstate,\n\t}).toString();\n\n\tconst callbackServer = await startOAuthCallbackServer(state, interaction.signal);\n\tinteraction.notify({ type: \"progress\", message: `Listening for OAuth callback on ${REDIRECT_URI}` });\n\tinteraction.notify({\n\t\ttype: \"auth_url\",\n\t\turl: authorizeUrl.toString(),\n\t\tinstructions: \"Continue in your browser.\",\n\t});\n\n\ttry {\n\t\tconst code = await callbackServer.waitForCode();\n\t\tif (!code) {\n\t\t\tif (interaction.signal?.aborted) {\n\t\t\t\tthrow new Error(\"Login cancelled\");\n\t\t\t}\n\t\t\tthrow new Error(\"OAuth callback did not complete.\");\n\t\t}\n\t\treturn await requestOAuthToken(\n\t\t\tgateway,\n\t\t\tnew URLSearchParams({\n\t\t\t\tgrant_type: \"authorization_code\",\n\t\t\t\tclient_id: OAUTH_CLIENT_ID,\n\t\t\t\tredirect_uri: REDIRECT_URI,\n\t\t\t\tcode,\n\t\t\t\tcode_verifier: verifier,\n\t\t\t}),\n\t\t\tinteraction.signal,\n\t\t);\n\t} finally {\n\t\tcallbackServer.close();\n\t}\n}\n\nasync function requestDeviceAuthorization(\n\tgateway: string,\n\tsignal: AbortSignal | undefined,\n): Promise<DeviceAuthorizationResponse> {\n\tlet response: Response;\n\ttry {\n\t\tresponse = await fetch(new URL(\"/v1/oauth/device\", gateway), {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { accept: \"application/json\", \"content-type\": \"application/x-www-form-urlencoded\" },\n\t\t\tbody: new URLSearchParams({ client_id: OAUTH_CLIENT_ID, scope: OAUTH_SCOPE }),\n\t\t\tsignal,\n\t\t});\n\t} catch (error) {\n\t\tif (signal?.aborted) {\n\t\t\tthrow new Error(\"Login cancelled\");\n\t\t}\n\t\tthrow error;\n\t}\n\n\tif (!response.ok) {\n\t\tthrow await readOAuthResponseError(response, \"Radius OAuth device authorization failed\");\n\t}\n\n\tconst data = (await response.json()) as Partial<DeviceAuthorizationResponse>;\n\tif (!data.device_code || !data.user_code || !data.verification_uri || !data.expires_in) {\n\t\tthrow new Error(\"Radius OAuth device authorization response is missing required fields\");\n\t}\n\n\treturn {\n\t\tdevice_code: data.device_code,\n\t\tuser_code: data.user_code,\n\t\tverification_uri: data.verification_uri,\n\t\texpires_in: data.expires_in,\n\t\tinterval: data.interval,\n\t};\n}\n\nasync function loginWithDeviceCode(gateway: string, interaction: AuthInteraction): Promise<OAuthCredential> {\n\tconst device = await requestDeviceAuthorization(gateway, interaction.signal);\n\tinteraction.notify({\n\t\ttype: \"device_code\",\n\t\tuserCode: device.user_code,\n\t\tverificationUri: device.verification_uri,\n\t\tintervalSeconds: device.interval,\n\t\texpiresInSeconds: device.expires_in,\n\t});\n\n\treturn pollOAuthDeviceCodeFlow<OAuthCredential>({\n\t\tintervalSeconds: device.interval,\n\t\texpiresInSeconds: device.expires_in,\n\t\tsignal: interaction.signal,\n\t\tpoll: async () => {\n\t\t\ttry {\n\t\t\t\tconst credentials = await requestOAuthToken(\n\t\t\t\t\tgateway,\n\t\t\t\t\tnew URLSearchParams({\n\t\t\t\t\t\tgrant_type: OAUTH_DEVICE_CODE_GRANT_TYPE,\n\t\t\t\t\t\tclient_id: OAUTH_CLIENT_ID,\n\t\t\t\t\t\tdevice_code: device.device_code,\n\t\t\t\t\t}),\n\t\t\t\t\tinteraction.signal,\n\t\t\t\t);\n\t\t\t\treturn { status: \"complete\", value: credentials };\n\t\t\t} catch (error) {\n\t\t\t\tif (!(error instanceof OAuthResponseError)) {\n\t\t\t\t\tthrow error;\n\t\t\t\t}\n\t\t\t\tswitch (error.oauthError) {\n\t\t\t\t\tcase \"authorization_pending\":\n\t\t\t\t\t\treturn { status: \"pending\" };\n\t\t\t\t\tcase \"slow_down\":\n\t\t\t\t\t\treturn { status: \"slow_down\" };\n\t\t\t\t\tcase \"expired_token\":\n\t\t\t\t\t\treturn { status: \"failed\", message: \"Device authorization expired.\" };\n\t\t\t\t\tcase \"access_denied\":\n\t\t\t\t\t\treturn { status: \"failed\", message: \"Device authorization was denied.\" };\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tthrow error;\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t});\n}\n\nexport interface RadiusOAuthOptions {\n\tname: string;\n\tgateway: string;\n}\n\nexport function createRadiusOAuth(options: RadiusOAuthOptions): OAuthAuth {\n\tconst gateway = normalizeRadiusGatewayUrl(options.gateway);\n\n\treturn {\n\t\tname: options.name,\n\n\t\tasync login(interaction): Promise<OAuthCredential> {\n\t\t\tconst loginMethod = await interaction.prompt({\n\t\t\t\ttype: \"select\",\n\t\t\t\tmessage: `Sign in to ${options.name}:`,\n\t\t\t\toptions: [\n\t\t\t\t\t{ id: LOGIN_METHOD_BROWSER, label: \"Sign in with browser (recommended)\" },\n\t\t\t\t\t{\n\t\t\t\t\t\tid: LOGIN_METHOD_DEVICE_CODE,\n\t\t\t\t\t\tlabel: \"Sign in with device code (when signing in from another device)\",\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tif (loginMethod === LOGIN_METHOD_DEVICE_CODE) {\n\t\t\t\treturn loginWithDeviceCode(gateway, interaction);\n\t\t\t}\n\t\t\tif (loginMethod === LOGIN_METHOD_BROWSER) {\n\t\t\t\tconst discovery = await loadRadiusOAuthDiscovery(gateway);\n\t\t\t\treturn loginWithBrowser(gateway, discovery.authorizationEndpoint, interaction);\n\t\t\t}\n\t\t\tthrow new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`);\n\t\t},\n\n\t\tasync refresh(credential, signal): Promise<OAuthCredential> {\n\t\t\tconst refreshed = await requestOAuthToken(\n\t\t\t\tgateway,\n\t\t\t\tnew URLSearchParams({\n\t\t\t\t\tgrant_type: \"refresh_token\",\n\t\t\t\t\tclient_id: OAUTH_CLIENT_ID,\n\t\t\t\t\trefresh_token: credential.refresh,\n\t\t\t\t}),\n\t\t\t\tsignal,\n\t\t\t);\n\t\t\treturn refreshed;\n\t\t},\n\n\t\tasync toAuth(credential) {\n\t\t\treturn { apiKey: credential.access };\n\t\t},\n\t};\n}\n"]}
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Radius gateway OAuth flow.
3
3
  *
4
- * Radius is a pi-messages gateway. OAuth endpoints are discovered from the
5
- * gateway (`/v1/oauth`); model catalog loading is owned by the Radius provider.
4
+ * Radius is a pi-messages gateway. OAuth client APIs live on the configured
5
+ * gateway; only the interactive browser authorization endpoint is discovered.
6
+ * Model catalog loading is owned by the Radius provider.
6
7
  *
7
8
  * NOTE: This module uses node:http for the OAuth callback server.
8
9
  * It is only intended for CLI use, not browser environments.
@@ -25,14 +26,21 @@ const REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`;
25
26
  const TOKEN_EXPIRY_SKEW_MS = 60_000;
26
27
  const LOGIN_METHOD_BROWSER = "browser";
27
28
  const LOGIN_METHOD_DEVICE_CODE = "device-code";
28
- async function loadRadiusOAuthConfig(gateway) {
29
+ const OAUTH_CLIENT_ID = "pi-gateway";
30
+ const OAUTH_SCOPE = "gateway offline_access";
31
+ const OAUTH_DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
32
+ async function loadRadiusOAuthDiscovery(gateway) {
29
33
  const response = await fetch(new URL("/v1/oauth", gateway), {
30
34
  headers: { accept: "application/json" },
31
35
  });
32
36
  if (!response.ok) {
33
37
  throw new Error(`Could not load Radius OAuth config from ${gateway}: ${response.status} ${await response.text()}`);
34
38
  }
35
- return (await response.json());
39
+ const discovery = (await response.json());
40
+ if (typeof discovery.authorizationEndpoint !== "string") {
41
+ throw new Error(`Invalid Radius OAuth config from ${gateway}`);
42
+ }
43
+ return { authorizationEndpoint: discovery.authorizationEndpoint };
36
44
  }
37
45
  class OAuthResponseError extends Error {
38
46
  status;
@@ -64,10 +72,10 @@ async function readOAuthResponseError(response, message) {
64
72
  }
65
73
  return new OAuthResponseError(response.status, oauthError, description, message);
66
74
  }
67
- async function requestOAuthToken(oauth, body, signal) {
75
+ async function requestOAuthToken(gateway, body, signal) {
68
76
  let response;
69
77
  try {
70
- response = await fetch(oauth.tokenEndpoint, {
78
+ response = await fetch(new URL("/v1/oauth/token", gateway), {
71
79
  method: "POST",
72
80
  headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
73
81
  body,
@@ -157,15 +165,15 @@ function startOAuthCallbackServer(expectedState, signal) {
157
165
  });
158
166
  });
159
167
  }
160
- async function loginWithBrowser(oauth, interaction) {
168
+ async function loginWithBrowser(gateway, authorizationEndpoint, interaction) {
161
169
  const { verifier, challenge } = await generatePKCE();
162
170
  const state = crypto.randomUUID();
163
- const authorizeUrl = new URL(oauth.authorizationEndpoint);
171
+ const authorizeUrl = new URL(authorizationEndpoint);
164
172
  authorizeUrl.search = new URLSearchParams({
165
173
  response_type: "code",
166
- client_id: oauth.clientId,
174
+ client_id: OAUTH_CLIENT_ID,
167
175
  redirect_uri: REDIRECT_URI,
168
- scope: oauth.scope,
176
+ scope: OAUTH_SCOPE,
169
177
  code_challenge: challenge,
170
178
  code_challenge_method: "S256",
171
179
  handoff: "url",
@@ -186,9 +194,9 @@ async function loginWithBrowser(oauth, interaction) {
186
194
  }
187
195
  throw new Error("OAuth callback did not complete.");
188
196
  }
189
- return await requestOAuthToken(oauth, new URLSearchParams({
197
+ return await requestOAuthToken(gateway, new URLSearchParams({
190
198
  grant_type: "authorization_code",
191
- client_id: oauth.clientId,
199
+ client_id: OAUTH_CLIENT_ID,
192
200
  redirect_uri: REDIRECT_URI,
193
201
  code,
194
202
  code_verifier: verifier,
@@ -198,13 +206,13 @@ async function loginWithBrowser(oauth, interaction) {
198
206
  callbackServer.close();
199
207
  }
200
208
  }
201
- async function requestDeviceAuthorization(oauth, signal) {
209
+ async function requestDeviceAuthorization(gateway, signal) {
202
210
  let response;
203
211
  try {
204
- response = await fetch(oauth.deviceAuthorizationEndpoint, {
212
+ response = await fetch(new URL("/v1/oauth/device", gateway), {
205
213
  method: "POST",
206
214
  headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
207
- body: new URLSearchParams({ client_id: oauth.clientId, scope: oauth.scope }),
215
+ body: new URLSearchParams({ client_id: OAUTH_CLIENT_ID, scope: OAUTH_SCOPE }),
208
216
  signal,
209
217
  });
210
218
  }
@@ -218,24 +226,23 @@ async function requestDeviceAuthorization(oauth, signal) {
218
226
  throw await readOAuthResponseError(response, "Radius OAuth device authorization failed");
219
227
  }
220
228
  const data = (await response.json());
221
- if (!data.device_code || !data.user_code || !data.expires_in) {
229
+ if (!data.device_code || !data.user_code || !data.verification_uri || !data.expires_in) {
222
230
  throw new Error("Radius OAuth device authorization response is missing required fields");
223
231
  }
224
232
  return {
225
233
  device_code: data.device_code,
226
234
  user_code: data.user_code,
227
235
  verification_uri: data.verification_uri,
228
- verification_uri_complete: data.verification_uri_complete,
229
236
  expires_in: data.expires_in,
230
237
  interval: data.interval,
231
238
  };
232
239
  }
233
- async function loginWithDeviceCode(oauth, interaction) {
234
- const device = await requestDeviceAuthorization(oauth, interaction.signal);
240
+ async function loginWithDeviceCode(gateway, interaction) {
241
+ const device = await requestDeviceAuthorization(gateway, interaction.signal);
235
242
  interaction.notify({
236
243
  type: "device_code",
237
244
  userCode: device.user_code,
238
- verificationUri: device.verification_uri || oauth.verificationEndpoint,
245
+ verificationUri: device.verification_uri,
239
246
  intervalSeconds: device.interval,
240
247
  expiresInSeconds: device.expires_in,
241
248
  });
@@ -245,9 +252,9 @@ async function loginWithDeviceCode(oauth, interaction) {
245
252
  signal: interaction.signal,
246
253
  poll: async () => {
247
254
  try {
248
- const credentials = await requestOAuthToken(oauth, new URLSearchParams({
249
- grant_type: oauth.deviceCodeGrantType,
250
- client_id: oauth.clientId,
255
+ const credentials = await requestOAuthToken(gateway, new URLSearchParams({
256
+ grant_type: OAUTH_DEVICE_CODE_GRANT_TYPE,
257
+ client_id: OAUTH_CLIENT_ID,
251
258
  device_code: device.device_code,
252
259
  }), interaction.signal);
253
260
  return { status: "complete", value: credentials };
@@ -277,7 +284,6 @@ export function createRadiusOAuth(options) {
277
284
  return {
278
285
  name: options.name,
279
286
  async login(interaction) {
280
- const oauth = await loadRadiusOAuthConfig(gateway);
281
287
  const loginMethod = await interaction.prompt({
282
288
  type: "select",
283
289
  message: `Sign in to ${options.name}:`,
@@ -289,23 +295,19 @@ export function createRadiusOAuth(options) {
289
295
  },
290
296
  ],
291
297
  });
292
- let credential;
293
298
  if (loginMethod === LOGIN_METHOD_DEVICE_CODE) {
294
- credential = await loginWithDeviceCode(oauth, interaction);
295
- }
296
- else if (loginMethod === LOGIN_METHOD_BROWSER) {
297
- credential = await loginWithBrowser(oauth, interaction);
299
+ return loginWithDeviceCode(gateway, interaction);
298
300
  }
299
- else {
300
- throw new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`);
301
+ if (loginMethod === LOGIN_METHOD_BROWSER) {
302
+ const discovery = await loadRadiusOAuthDiscovery(gateway);
303
+ return loginWithBrowser(gateway, discovery.authorizationEndpoint, interaction);
301
304
  }
302
- return credential;
305
+ throw new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`);
303
306
  },
304
307
  async refresh(credential, signal) {
305
- const oauth = await loadRadiusOAuthConfig(gateway);
306
- const refreshed = await requestOAuthToken(oauth, new URLSearchParams({
308
+ const refreshed = await requestOAuthToken(gateway, new URLSearchParams({
307
309
  grant_type: "refresh_token",
308
- client_id: oauth.clientId,
310
+ client_id: OAUTH_CLIENT_ID,
309
311
  refresh_token: credential.refresh,
310
312
  }), signal);
311
313
  return refreshed;