From a9f5b1c123a4f13457e88522a3be89aeb4823d66 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 24 Jul 2026 16:12:44 +0200 Subject: [PATCH] fix(ai): route Radius OAuth through gateway --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/auth/oauth/radius.ts | 90 +++++++++---------- packages/ai/test/radius-oauth.test.ts | 125 ++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 46 deletions(-) create mode 100644 packages/ai/test/radius-oauth.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7c7dcd0fe..9e1b31c6f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -6,6 +6,10 @@ - Added `ANTHROPIC_AUTH_TOKEN` bearer authentication for Anthropic-compatible gateways ([#5871](https://github.com/earendil-works/pi/issues/5871)) +### Changed + +- Changed Radius OAuth device authorization, token exchange, and refresh requests to use the configured gateway directly. + ## [0.82.0] - 2026-07-24 ### Breaking Changes diff --git a/packages/ai/src/auth/oauth/radius.ts b/packages/ai/src/auth/oauth/radius.ts index 8e5f321ac..655316910 100644 --- a/packages/ai/src/auth/oauth/radius.ts +++ b/packages/ai/src/auth/oauth/radius.ts @@ -1,8 +1,9 @@ /** * Radius gateway OAuth flow. * - * Radius is a pi-messages gateway. OAuth endpoints are discovered from the - * gateway (`/v1/oauth`); model catalog loading is owned by the Radius provider. + * 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 for the OAuth callback server. * It is only intended for CLI use, not browser environments. @@ -29,29 +30,23 @@ 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"; -type RadiusOAuthConfig = { - issuer: string; +type RadiusOAuthDiscovery = { authorizationEndpoint: string; - tokenEndpoint: string; - deviceAuthorizationEndpoint: string; - deviceAuthorizationEventsEndpoint: string; - verificationEndpoint: string; - clientId: string; - scope: string; - deviceCodeGrantType: string; }; type DeviceAuthorizationResponse = { device_code: string; user_code: string; - verification_uri?: string; - verification_uri_complete?: string; + verification_uri: string; expires_in: number; interval?: number; }; -async function loadRadiusOAuthConfig(gateway: string): Promise { +async function loadRadiusOAuthDiscovery(gateway: string): Promise { const response = await fetch(new URL("/v1/oauth", gateway), { headers: { accept: "application/json" }, }); @@ -62,7 +57,11 @@ async function loadRadiusOAuthConfig(gateway: string): Promise; + if (typeof discovery.authorizationEndpoint !== "string") { + throw new Error(`Invalid Radius OAuth config from ${gateway}`); + } + return { authorizationEndpoint: discovery.authorizationEndpoint }; } class OAuthResponseError extends Error { @@ -100,13 +99,13 @@ async function readOAuthResponseError(response: Response, message: string): Prom } async function requestOAuthToken( - oauth: RadiusOAuthConfig, + gateway: string, body: URLSearchParams, signal?: AbortSignal, ): Promise { let response: Response; try { - response = await fetch(oauth.tokenEndpoint, { + response = await fetch(new URL("/v1/oauth/token", gateway), { method: "POST", headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, body, @@ -220,15 +219,19 @@ function startOAuthCallbackServer( }); } -async function loginWithBrowser(oauth: RadiusOAuthConfig, interaction: AuthInteraction): Promise { +async function loginWithBrowser( + gateway: string, + authorizationEndpoint: string, + interaction: AuthInteraction, +): Promise { const { verifier, challenge } = await generatePKCE(); const state = crypto.randomUUID(); - const authorizeUrl = new URL(oauth.authorizationEndpoint); + const authorizeUrl = new URL(authorizationEndpoint); authorizeUrl.search = new URLSearchParams({ response_type: "code", - client_id: oauth.clientId, + client_id: OAUTH_CLIENT_ID, redirect_uri: REDIRECT_URI, - scope: oauth.scope, + scope: OAUTH_SCOPE, code_challenge: challenge, code_challenge_method: "S256", handoff: "url", @@ -252,10 +255,10 @@ async function loginWithBrowser(oauth: RadiusOAuthConfig, interaction: AuthInter throw new Error("OAuth callback did not complete."); } return await requestOAuthToken( - oauth, + gateway, new URLSearchParams({ grant_type: "authorization_code", - client_id: oauth.clientId, + client_id: OAUTH_CLIENT_ID, redirect_uri: REDIRECT_URI, code, code_verifier: verifier, @@ -268,15 +271,15 @@ async function loginWithBrowser(oauth: RadiusOAuthConfig, interaction: AuthInter } async function requestDeviceAuthorization( - oauth: RadiusOAuthConfig, + gateway: string, signal: AbortSignal | undefined, ): Promise { let response: Response; try { - response = await fetch(oauth.deviceAuthorizationEndpoint, { + 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.clientId, scope: oauth.scope }), + body: new URLSearchParams({ client_id: OAUTH_CLIENT_ID, scope: OAUTH_SCOPE }), signal, }); } catch (error) { @@ -291,7 +294,7 @@ async function requestDeviceAuthorization( } const data = (await response.json()) as Partial; - if (!data.device_code || !data.user_code || !data.expires_in) { + 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"); } @@ -299,18 +302,17 @@ async function requestDeviceAuthorization( device_code: data.device_code, user_code: data.user_code, verification_uri: data.verification_uri, - verification_uri_complete: data.verification_uri_complete, expires_in: data.expires_in, interval: data.interval, }; } -async function loginWithDeviceCode(oauth: RadiusOAuthConfig, interaction: AuthInteraction): Promise { - const device = await requestDeviceAuthorization(oauth, interaction.signal); +async function loginWithDeviceCode(gateway: string, interaction: AuthInteraction): Promise { + const device = await requestDeviceAuthorization(gateway, interaction.signal); interaction.notify({ type: "device_code", userCode: device.user_code, - verificationUri: device.verification_uri || oauth.verificationEndpoint, + verificationUri: device.verification_uri, intervalSeconds: device.interval, expiresInSeconds: device.expires_in, }); @@ -322,10 +324,10 @@ async function loginWithDeviceCode(oauth: RadiusOAuthConfig, interaction: AuthIn poll: async () => { try { const credentials = await requestOAuthToken( - oauth, + gateway, new URLSearchParams({ - grant_type: oauth.deviceCodeGrantType, - client_id: oauth.clientId, + grant_type: OAUTH_DEVICE_CODE_GRANT_TYPE, + client_id: OAUTH_CLIENT_ID, device_code: device.device_code, }), interaction.signal, @@ -364,7 +366,6 @@ export function createRadiusOAuth(options: RadiusOAuthOptions): OAuthAuth { name: options.name, async login(interaction): Promise { - const oauth = await loadRadiusOAuthConfig(gateway); const loginMethod = await interaction.prompt({ type: "select", message: `Sign in to ${options.name}:`, @@ -377,25 +378,22 @@ export function createRadiusOAuth(options: RadiusOAuthOptions): OAuthAuth { ], }); - let credential: OAuthCredential; if (loginMethod === LOGIN_METHOD_DEVICE_CODE) { - credential = await loginWithDeviceCode(oauth, interaction); - } else if (loginMethod === LOGIN_METHOD_BROWSER) { - credential = await loginWithBrowser(oauth, interaction); - } else { - throw new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`); + return loginWithDeviceCode(gateway, interaction); } - - return credential; + if (loginMethod === LOGIN_METHOD_BROWSER) { + const discovery = await loadRadiusOAuthDiscovery(gateway); + return loginWithBrowser(gateway, discovery.authorizationEndpoint, interaction); + } + throw new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`); }, async refresh(credential, signal): Promise { - const oauth = await loadRadiusOAuthConfig(gateway); const refreshed = await requestOAuthToken( - oauth, + gateway, new URLSearchParams({ grant_type: "refresh_token", - client_id: oauth.clientId, + client_id: OAUTH_CLIENT_ID, refresh_token: credential.refresh, }), signal, diff --git a/packages/ai/test/radius-oauth.test.ts b/packages/ai/test/radius-oauth.test.ts new file mode 100644 index 000000000..762c8c25d --- /dev/null +++ b/packages/ai/test/radius-oauth.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRadiusOAuth } from "../src/auth/oauth/radius.ts"; +import type { AuthEvent, AuthInteraction } from "../src/auth/types.ts"; + +const GATEWAY = "https://radius.example"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function requestUrl(input: unknown): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + if (input instanceof Request) return input.url; + throw new Error(`Unsupported request input: ${String(input)}`); +} + +function interaction(loginMethod: "browser" | "device-code", events: AuthEvent[] = []): AuthInteraction { + return { + prompt: async () => loginMethod, + notify: (event) => events.push(event), + }; +} + +describe("Radius OAuth", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("uses gateway endpoints directly for device login", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-24T00:00:00Z")); + const events: AuthEvent[] = []; + const urls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit) => { + const url = requestUrl(input); + urls.push(url); + const form = new URLSearchParams(String(init?.body)); + if (url === `${GATEWAY}/v1/oauth/device`) { + expect(form.get("client_id")).toBe("pi-gateway"); + expect(form.get("scope")).toBe("gateway offline_access"); + return jsonResponse({ + device_code: "device-code", + user_code: "ABCD-1234", + verification_uri: "https://radius-ui.example/pair", + expires_in: 600, + interval: 5, + }); + } + if (url === `${GATEWAY}/v1/oauth/token`) { + expect(form.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code"); + expect(form.get("client_id")).toBe("pi-gateway"); + expect(form.get("device_code")).toBe("device-code"); + return jsonResponse({ + access_token: "access-token", + refresh_token: "refresh-token", + expires_in: 3600, + scope: "gateway offline_access", + }); + } + throw new Error(`Unexpected request: ${url}`); + }), + ); + + const oauth = createRadiusOAuth({ name: "Radius", gateway: GATEWAY }); + await expect(oauth.login(interaction("device-code", events))).resolves.toEqual({ + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 3600 * 1000 - 60_000, + scope: "gateway offline_access", + }); + expect(events).toEqual([ + { + type: "device_code", + userCode: "ABCD-1234", + verificationUri: "https://radius-ui.example/pair", + intervalSeconds: 5, + expiresInSeconds: 600, + }, + ]); + expect(urls).toEqual([`${GATEWAY}/v1/oauth/device`, `${GATEWAY}/v1/oauth/token`]); + }); + + it("refreshes directly through the gateway without discovery", async () => { + const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => { + expect(requestUrl(input)).toBe(`${GATEWAY}/v1/oauth/token`); + const form = new URLSearchParams(String(init?.body)); + expect(form.get("grant_type")).toBe("refresh_token"); + expect(form.get("client_id")).toBe("pi-gateway"); + expect(form.get("refresh_token")).toBe("old-refresh"); + return jsonResponse({ + access_token: "new-access", + refresh_token: "new-refresh", + expires_in: 3600, + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const oauth = createRadiusOAuth({ name: "Radius", gateway: GATEWAY }); + await expect( + oauth.refresh({ type: "oauth", access: "old-access", refresh: "old-refresh", expires: 0 }), + ).resolves.toMatchObject({ access: "new-access", refresh: "new-refresh" }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("discovers only the interactive browser authorization endpoint", async () => { + const fetchMock = vi.fn(async (input: unknown) => { + expect(requestUrl(input)).toBe(`${GATEWAY}/v1/oauth`); + return jsonResponse({ issuer: "https://radius-ui.example" }); + }); + vi.stubGlobal("fetch", fetchMock); + + const oauth = createRadiusOAuth({ name: "Radius", gateway: GATEWAY }); + await expect(oauth.login(interaction("browser"))).rejects.toThrow(`Invalid Radius OAuth config from ${GATEWAY}`); + expect(fetchMock).toHaveBeenCalledOnce(); + }); +});