mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 11:24:57 -05:00
fix(oauth-provider): support prompt=none (#8554)
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? {});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user