UNPKG

@earendil-works/pi-ai

Version:

Unified LLM API with automatic model discovery and provider configuration

259 lines • 9.98 kB
/** * Radius gateway OAuth flow. * * Radius is a pi-messages gateway. OAuth client APIs live on the configured * gateway; only the interactive browser authorization endpoint is discovered. * Model catalog loading is owned by the Radius provider. * * NOTE: This module uses node:http (via callback-server.ts) for the OAuth callback server. * It is only intended for CLI use, not browser environments. */ import { normalizeRadiusGatewayUrl } from "../../providers/radius-config.js"; import { startOAuthCallbackServer } from "./callback-server.js"; import { pollOAuthDeviceCodeFlow } from "./device-code.js"; import { generatePKCE } from "./pkce.js"; import { trustedHttpUrl } from "./trusted-url.js"; const CALLBACK_HOST = "127.0.0.1"; const CALLBACK_PORT = 1456; const CALLBACK_PATH = "/oauth/callback"; const REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`; const TOKEN_EXPIRY_SKEW_MS = 60_000; const LOGIN_METHOD_BROWSER = "browser"; const LOGIN_METHOD_DEVICE_CODE = "device-code"; const OAUTH_CLIENT_ID = "pi-gateway"; const OAUTH_SCOPE = "gateway offline_access"; const OAUTH_DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; async function loadRadiusOAuthDiscovery(gateway, signal) { const response = await fetch(new URL("/v1/oauth", gateway), { headers: { accept: "application/json" }, signal, }); if (!response.ok) { throw new Error(`Could not load Radius OAuth config from ${gateway}: ${response.status} ${await response.text()}`); } const discovery = (await response.json()); const authorizationEndpoint = trustedHttpUrl(discovery.authorizationEndpoint); if (!authorizationEndpoint) { throw new Error(`Invalid Radius OAuth config from ${gateway}`); } return { authorizationEndpoint }; } class OAuthResponseError extends Error { status; oauthError; constructor(status, oauthError, description, message) { const detail = oauthError ? description ? `${oauthError}: ${description}` : oauthError : description || String(status); super(`${message}: ${detail}`); this.status = status; this.oauthError = oauthError; } } async function readOAuthResponseError(response, message) { const text = await response.text().catch(() => ""); let oauthError; let description; if (text) { try { const data = JSON.parse(text); oauthError = typeof data.error === "string" ? data.error : undefined; description = typeof data.error_description === "string" ? data.error_description : undefined; } catch { description = text; } } return new OAuthResponseError(response.status, oauthError, description, message); } async function requestOAuthToken(gateway, body, signal) { let response; try { response = await fetch(new URL("/v1/oauth/token", gateway), { method: "POST", headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, body, signal, }); } catch (error) { if (signal.aborted) { throw new Error("Login cancelled"); } throw error; } if (!response.ok) { throw await readOAuthResponseError(response, "Radius OAuth token request failed"); } const data = (await response.json()); return { type: "oauth", access: data.access_token, refresh: data.refresh_token, expires: Date.now() + data.expires_in * 1000 - TOKEN_EXPIRY_SKEW_MS, scope: data.scope, }; } async function loginWithBrowser(gateway, authorizationEndpoint, interaction) { const { verifier, challenge } = await generatePKCE(); const state = crypto.randomUUID(); const authorizeUrl = new URL(authorizationEndpoint); authorizeUrl.search = new URLSearchParams({ response_type: "code", client_id: OAUTH_CLIENT_ID, redirect_uri: REDIRECT_URI, scope: OAUTH_SCOPE, code_challenge: challenge, code_challenge_method: "S256", handoff: "url", state, }).toString(); const callback = await startOAuthCallbackServer({ providerName: "Radius", host: CALLBACK_HOST, port: CALLBACK_PORT, path: CALLBACK_PATH, state, complete: (code) => requestOAuthToken(gateway, new URLSearchParams({ grant_type: "authorization_code", client_id: OAUTH_CLIENT_ID, redirect_uri: REDIRECT_URI, code, code_verifier: verifier, }), interaction.signal), signal: interaction.signal, }); interaction.notify({ type: "progress", message: `Listening for OAuth callback on ${REDIRECT_URI}` }); interaction.notify({ type: "auth_url", url: authorizeUrl.toString(), instructions: "Continue in your browser.", }); try { const credential = await callback.wait(); if (!credential) throw new Error("OAuth callback did not complete."); return credential; } finally { callback.close(); } } async function requestDeviceAuthorization(gateway, signal) { let response; try { response = await fetch(new URL("/v1/oauth/device", gateway), { method: "POST", headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: OAUTH_CLIENT_ID, scope: OAUTH_SCOPE }), signal, }); } catch (error) { if (signal.aborted) { throw new Error("Login cancelled"); } throw error; } if (!response.ok) { throw await readOAuthResponseError(response, "Radius OAuth device authorization failed"); } const data = (await response.json()); if (!data.device_code || !data.user_code || !data.verification_uri || !data.expires_in) { throw new Error("Radius OAuth device authorization response is missing required fields"); } const verificationUri = trustedHttpUrl(data.verification_uri); if (!verificationUri) { throw new Error("Untrusted verification_uri in Radius OAuth device authorization response"); } return { device_code: data.device_code, user_code: data.user_code, verification_uri: verificationUri, expires_in: data.expires_in, interval: data.interval, }; } async function loginWithDeviceCode(gateway, interaction) { const device = await requestDeviceAuthorization(gateway, interaction.signal); interaction.notify({ type: "device_code", userCode: device.user_code, verificationUri: device.verification_uri, intervalSeconds: device.interval, expiresInSeconds: device.expires_in, }); return pollOAuthDeviceCodeFlow({ intervalSeconds: device.interval, expiresInSeconds: device.expires_in, signal: interaction.signal, poll: async () => { try { const credentials = await requestOAuthToken(gateway, new URLSearchParams({ grant_type: OAUTH_DEVICE_CODE_GRANT_TYPE, client_id: OAUTH_CLIENT_ID, device_code: device.device_code, }), interaction.signal); return { status: "complete", value: credentials }; } catch (error) { if (!(error instanceof OAuthResponseError)) { throw error; } switch (error.oauthError) { case "authorization_pending": return { status: "pending" }; case "slow_down": return { status: "slow_down" }; case "expired_token": return { status: "failed", message: "Device authorization expired." }; case "access_denied": return { status: "failed", message: "Device authorization was denied." }; default: throw error; } } }, }); } export function createRadiusOAuth(options) { const gateway = normalizeRadiusGatewayUrl(options.gateway); return { name: options.name, async login(interaction) { const loginMethod = await interaction.prompt({ type: "select", message: `Sign in to ${options.name}:`, options: [ { id: LOGIN_METHOD_BROWSER, label: "Sign in with browser (recommended)" }, { id: LOGIN_METHOD_DEVICE_CODE, label: "Sign in with device code (when signing in from another device)", }, ], }); if (loginMethod === LOGIN_METHOD_DEVICE_CODE) { return loginWithDeviceCode(gateway, interaction); } if (loginMethod === LOGIN_METHOD_BROWSER) { const discovery = await loadRadiusOAuthDiscovery(gateway, interaction.signal); return loginWithBrowser(gateway, discovery.authorizationEndpoint, interaction); } throw new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`); }, async refresh(credential, signal) { const refreshed = await requestOAuthToken(gateway, new URLSearchParams({ grant_type: "refresh_token", client_id: OAUTH_CLIENT_ID, refresh_token: credential.refresh, }), signal); return refreshed; }, async toAuth(credential) { return { apiKey: credential.access }; }, }; } //# sourceMappingURL=radius.js.map