From 812fd4d8efb5b790ed372c266f156d59ea02e548 Mon Sep 17 00:00:00 2001 From: Dylan Vanmali Date: Wed, 11 Mar 2026 11:02:37 -0700 Subject: [PATCH] fix(oauth-provider): support prompt=none (#8554) --- packages/oauth-provider/src/authorize.test.ts | 76 +++++++++++++ packages/oauth-provider/src/authorize.ts | 75 +++++++++++++ packages/oauth-provider/src/metadata.test.ts | 8 +- packages/oauth-provider/src/metadata.ts | 8 +- packages/oauth-provider/src/oauth.test.ts | 103 ++++++++++++++++++ 5 files changed, 268 insertions(+), 2 deletions(-) diff --git a/packages/oauth-provider/src/authorize.test.ts b/packages/oauth-provider/src/authorize.test.ts index 218b86ad04..d0cb415b19 100644 --- a/packages/oauth-provider/src/authorize.test.ts +++ b/packages/oauth-provider/src/authorize.test.ts @@ -148,6 +148,37 @@ describe("oauth authorize - unauthenticated", async () => { `redirect_uri=${encodeURIComponent(redirectUri)}`, ); }); + + it("should return login_required when prompt=none and user is not logged in", async () => { + if (!oauthClient?.client_id) { + throw Error("beforeAll not run properly"); + } + + const authUrl = new URL(`${authServerBaseUrl}/api/auth/oauth2/authorize`); + authUrl.searchParams.set("client_id", oauthClient.client_id); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", "openid"); + authUrl.searchParams.set("state", "prompt-none-login-required"); + authUrl.searchParams.set("prompt", "none"); + authUrl.searchParams.set("code_challenge", generateRandomString(43)); + authUrl.searchParams.set("code_challenge_method", "S256"); + + let callbackRedirectUrl = ""; + await unauthenticatedClient.$fetch(authUrl.toString(), { + onError(context) { + callbackRedirectUrl = context.response.headers.get("Location") || ""; + }, + }); + + expect(callbackRedirectUrl).toContain(redirectUri); + expect(callbackRedirectUrl).toContain("error=login_required"); + expect(callbackRedirectUrl).toContain("state=prompt-none-login-required"); + expect(callbackRedirectUrl).toContain( + `iss=${encodeURIComponent(authServerBaseUrl)}`, + ); + expect(callbackRedirectUrl).not.toContain("/login"); + }); }); describe("oauth authorize - authenticated", async () => { @@ -179,6 +210,7 @@ describe("oauth authorize - authenticated", async () => { }); let oauthClient: OAuthClient | null; + let oauthClientNeedsConsent: OAuthClient | null; const providerId = "test"; const redirectUri = `${rpBaseUrl}/api/auth/oauth2/callback/${providerId}`; // Registers a confidential client application to work with @@ -195,6 +227,19 @@ describe("oauth authorize - authenticated", async () => { expect(response?.client_secret).toBeDefined(); expect(response?.redirect_uris).toEqual([redirectUri]); oauthClient = response; + + const responseNeedsConsent = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUri], + skip_consent: false, + }, + }); + expect(responseNeedsConsent?.client_id).toBeDefined(); + expect(responseNeedsConsent?.user_id).toBeDefined(); + expect(responseNeedsConsent?.client_secret).toBeDefined(); + expect(responseNeedsConsent?.redirect_uris).toEqual([redirectUri]); + oauthClientNeedsConsent = responseNeedsConsent; }); it("should authorize and include iss parameter", async () => { @@ -292,4 +337,35 @@ describe("oauth authorize - authenticated", async () => { expect(issParam).toBe(metadataIssuer); }); + + it("should return consent_required when prompt=none and consent is needed", async () => { + if (!oauthClientNeedsConsent?.client_id) { + throw Error("beforeAll not run properly"); + } + + const authUrl = new URL(`${authServerBaseUrl}/api/auth/oauth2/authorize`); + authUrl.searchParams.set("client_id", oauthClientNeedsConsent.client_id); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", "openid"); + authUrl.searchParams.set("state", "prompt-none-consent-required"); + authUrl.searchParams.set("prompt", "none"); + authUrl.searchParams.set("code_challenge", generateRandomString(43)); + authUrl.searchParams.set("code_challenge_method", "S256"); + + let callbackRedirectUrl = ""; + await client.$fetch(authUrl.toString(), { + onError(context) { + callbackRedirectUrl = context.response.headers.get("Location") || ""; + }, + }); + + expect(callbackRedirectUrl).toContain(redirectUri); + expect(callbackRedirectUrl).toContain("error=consent_required"); + expect(callbackRedirectUrl).toContain("state=prompt-none-consent-required"); + expect(callbackRedirectUrl).toContain( + `iss=${encodeURIComponent(authServerBaseUrl)}`, + ); + expect(callbackRedirectUrl).not.toContain("/consent"); + }); }); diff --git a/packages/oauth-provider/src/authorize.ts b/packages/oauth-provider/src/authorize.ts index 561555b721..a6e1d896ff 100644 --- a/packages/oauth-provider/src/authorize.ts +++ b/packages/oauth-provider/src/authorize.ts @@ -21,6 +21,16 @@ import { storeToken, } from "./utils"; +/** + * OIDC Error Codes + * @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError + */ +type OIDCAuthError = + | "login_required" + | "consent_required" + | "interaction_required" + | "account_selection_required"; + /** * Formats an error url */ @@ -53,6 +63,25 @@ export const handleRedirect = (ctx: GenericEndpointContext, uri: string) => { } }; +function redirectWithPromptNoneError( + ctx: GenericEndpointContext, + opts: OAuthOptions, + query: OAuthAuthorizationQuery, + error: OIDCAuthError, + description: string, +) { + return handleRedirect( + ctx, + formatErrorURL( + query.redirect_uri, + error, + description, + query.state, + getIssuer(ctx, opts), + ), + ); +} + /** * Validates that the issuer URL * - MUST use HTTPS scheme (HTTP allowed for localhost in dev) @@ -160,6 +189,7 @@ export async function authorizeEndpoint( const promptSet = ctx.query?.prompt ? parsePrompt(ctx.query?.prompt) : undefined; + const promptNone = promptSet?.has("none") ?? false; if (promptSet?.has("select_account") && !opts.selectAccount?.page) { throw ctx.redirect( getErrorURL( @@ -278,6 +308,15 @@ export async function authorizeEndpoint( // Check for session const session = await getSessionFromCtx(ctx); if (!session || promptSet?.has("login") || promptSet?.has("create")) { + if (promptNone) { + return redirectWithPromptNoneError( + ctx, + opts, + query, + "login_required", + "authentication required", + ); + } return redirectWithPromptCode( ctx, opts, @@ -302,6 +341,15 @@ export async function authorizeEndpoint( scopes: requestedScopes, }); if (selectedAccountRedirect) { + if (promptNone) { + return redirectWithPromptNoneError( + ctx, + opts, + query, + "account_selection_required", + "End-User account selection is required", + ); + } return redirectWithPromptCode(ctx, opts, "select_account"); } } @@ -315,6 +363,15 @@ export async function authorizeEndpoint( scopes: requestedScopes, }); if (signupRedirect) { + if (promptNone) { + return redirectWithPromptNoneError( + ctx, + opts, + query, + "interaction_required", + "End-User interaction is required", + ); + } return redirectWithPromptCode( ctx, opts, @@ -332,6 +389,15 @@ export async function authorizeEndpoint( scopes: requestedScopes, }); if (postLoginRedirect) { + if (promptNone) { + return redirectWithPromptNoneError( + ctx, + opts, + query, + "interaction_required", + "End-User interaction is required", + ); + } return redirectWithPromptCode(ctx, opts, "post_login"); } } @@ -384,6 +450,15 @@ export async function authorizeEndpoint( !consent || !requestedScopes.every((val) => consent.scopes.includes(val)) ) { + if (promptNone) { + return redirectWithPromptNoneError( + ctx, + opts, + query, + "consent_required", + "End-User consent is required", + ); + } return redirectWithPromptCode(ctx, opts, "consent"); } diff --git a/packages/oauth-provider/src/metadata.test.ts b/packages/oauth-provider/src/metadata.test.ts index 8c0f2533d9..69ecc28cbe 100644 --- a/packages/oauth-provider/src/metadata.test.ts +++ b/packages/oauth-provider/src/metadata.test.ts @@ -107,7 +107,13 @@ describe("oauth metadata", async () => { id_token_signing_alg_values_supported: ["EdDSA"], end_session_endpoint: `${baseURL}/oauth2/end-session`, acr_values_supported: ["urn:mace:incommon:iap:bronze"], - prompt_values_supported: ["login", "consent", "create", "select_account"], + prompt_values_supported: [ + "login", + "consent", + "create", + "select_account", + "none", + ], }); const oauthMetadata = await auth.api.getOAuthServerConfig(); expect(oauthMetadata).toMatchObject(metadata ?? {}); diff --git a/packages/oauth-provider/src/metadata.ts b/packages/oauth-provider/src/metadata.ts index 213dec4385..37eab3dc3d 100644 --- a/packages/oauth-provider/src/metadata.ts +++ b/packages/oauth-provider/src/metadata.ts @@ -100,7 +100,13 @@ export function oidcServerMetadata( : ["EdDSA"], end_session_endpoint: `${baseURL}/oauth2/end-session`, acr_values_supported: ["urn:mace:incommon:iap:bronze"], - prompt_values_supported: ["login", "consent", "create", "select_account"], + prompt_values_supported: [ + "login", + "consent", + "create", + "select_account", + "none", + ], }; return metadata; } diff --git a/packages/oauth-provider/src/oauth.test.ts b/packages/oauth-provider/src/oauth.test.ts index 3f82848439..7060229b96 100644 --- a/packages/oauth-provider/src/oauth.test.ts +++ b/packages/oauth-provider/src/oauth.test.ts @@ -1164,6 +1164,109 @@ describe("oauth - prompt", async () => { enableSelectAccount = false; }); + it("none - should return account_selection_required when account selection is required", async () => { + if (!oauthClient?.client_id || !oauthClient?.client_secret) { + throw Error("beforeAll not run properly"); + } + + enableSelectAccount = true; + try { + const { customFetchImpl: customFetchImplRP } = await createTestInstance({ + prompt: "none", + }); + const client = createAuthClient({ + plugins: [genericOAuthClient()], + baseURL: rpBaseUrl, + fetchOptions: { + customFetchImpl: customFetchImplRP, + }, + }); + + const data = await client.signIn.oauth2( + { + providerId, + callbackURL: "/success", + }, + { + headers, + throw: true, + }, + ); + expect(data.url).toContain(`prompt=none`); + + let callbackRedirectUri = ""; + await serverClient.$fetch(data.url, { + method: "GET", + headers, + onError(context) { + callbackRedirectUri = context.response.headers.get("Location") || ""; + }, + }); + + expect(callbackRedirectUri).toContain(redirectUri); + expect(callbackRedirectUri).toContain("error=account_selection_required"); + expect(callbackRedirectUri).toContain("state="); + expect(callbackRedirectUri).toContain( + `iss=${encodeURIComponent(authServerBaseUrl)}`, + ); + expect(callbackRedirectUri).not.toContain("/select-account"); + } finally { + enableSelectAccount = false; + } + }); + + it("none - should return interaction_required when post login is required", async () => { + if (!oauthClient?.client_id || !oauthClient?.client_secret) { + throw Error("beforeAll not run properly"); + } + + enableSelectAccount = false; + enablePostLogin = true; + try { + const { customFetchImpl: customFetchImplRP } = await createTestInstance({ + prompt: "none", + }); + const client = createAuthClient({ + plugins: [genericOAuthClient()], + baseURL: rpBaseUrl, + fetchOptions: { + customFetchImpl: customFetchImplRP, + }, + }); + + const data = await client.signIn.oauth2( + { + providerId, + callbackURL: "/success", + }, + { + headers, + throw: true, + }, + ); + expect(data.url).toContain(`prompt=none`); + + let callbackRedirectUri = ""; + await serverClient.$fetch(data.url, { + method: "GET", + headers, + onError(context) { + callbackRedirectUri = context.response.headers.get("Location") || ""; + }, + }); + + expect(callbackRedirectUri).toContain(redirectUri); + expect(callbackRedirectUri).toContain("error=interaction_required"); + expect(callbackRedirectUri).toContain("state="); + expect(callbackRedirectUri).toContain( + `iss=${encodeURIComponent(authServerBaseUrl)}`, + ); + expect(callbackRedirectUri).not.toContain("/select-organization"); + } finally { + enablePostLogin = false; + } + }); + it("login+consent - should always redirect to login and force consent (notice consent previously given)", async ({ onTestFinished, }) => {