fix(oauth-provider): support prompt=none (#8554)

This commit is contained in:
Dylan Vanmali
2026-03-13 23:46:35 -07:00
committed by Alex Yang
parent 7011997016
commit 812fd4d8ef
5 changed files with 268 additions and 2 deletions
@@ -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");
});
});
+75
View File
@@ -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<Scope[]>,
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");
}
+7 -1
View File
@@ -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 ?? {});
+7 -1
View File
@@ -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;
}
+103
View File
@@ -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,
}) => {