From d13cdd034ee4f3200abbfdd5ffb72ff703b5d432 Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Sat, 13 Dec 2025 21:17:22 +0000 Subject: [PATCH] feat(oidc-provider): support prompt=create --- docs/content/docs/plugins/oidc-provider.mdx | 6 ++ .../src/plugins/oidc-provider/authorize.ts | 13 +++- .../src/plugins/oidc-provider/index.ts | 7 +++ .../src/plugins/oidc-provider/oidc.test.ts | 60 +++++++++++++++++++ .../src/plugins/oidc-provider/types.ts | 15 ++++- .../oidc-provider/utils/prompt.test.ts | 5 +- .../src/plugins/oidc-provider/utils/prompt.ts | 8 ++- 7 files changed, 108 insertions(+), 6 deletions(-) diff --git a/docs/content/docs/plugins/oidc-provider.mdx b/docs/content/docs/plugins/oidc-provider.mdx index ab0c18f0fe..0a0c57a779 100644 --- a/docs/content/docs/plugins/oidc-provider.mdx +++ b/docs/content/docs/plugins/oidc-provider.mdx @@ -349,6 +349,10 @@ export const auth = betterAuth({ You don't need to handle anything from your side; when a new session is created, the plugin will handle continuing the authorization flow. +### Initiating Registration (`prompt=create`) + +Better Auth supports the OpenID Connect Prompt Create extension. When an authorization request includes `prompt=create` and the user is not authenticated, Better Auth will redirect the user to `createAccountPage` (if provided) instead of `loginPage`. If `createAccountPage` is not set, the user will be redirected to `loginPage` and you can render a sign-up experience based on the `prompt` query parameter. + ## Configuration ### OIDC Metadata @@ -620,6 +624,8 @@ Table Name: `oauthConsent` **loginPage**: `string` - Path to the custom login page. +**createAccountPage**: `string` - Path to the custom create account (sign-up) page. When an authorization request includes `prompt=create`, unauthenticated users will be redirected here instead of `loginPage`. + **consentPage**: `string` - Path to the custom consent page. **trustedClients**: `(Client & { skipConsent?: boolean })[]` - Array of trusted clients that are configured directly in the provider options. These clients bypass database lookups and can optionally skip consent screens. diff --git a/packages/better-auth/src/plugins/oidc-provider/authorize.ts b/packages/better-auth/src/plugins/oidc-provider/authorize.ts index 22a243413d..ed7f5773ba 100644 --- a/packages/better-auth/src/plugins/oidc-provider/authorize.ts +++ b/packages/better-auth/src/plugins/oidc-provider/authorize.ts @@ -12,6 +12,11 @@ function formatErrorURL(url: string, error: string, description: string) { }error=${error}&error_description=${description}`; } +function appendQuery(url: string, query: string) { + if (!query) return url; + return `${url}${url.includes("?") ? "&" : "?"}${query}`; +} + function getErrorURL( ctx: GenericEndpointContext, error: string, @@ -86,8 +91,12 @@ export async function authorize( sameSite: "lax", }, ); - const queryFromURL = ctx.request.url?.split("?")[1]!; - return handleRedirect(`${options.loginPage}?${queryFromURL}`); + const queryFromURL = ctx.request.url?.split("?")[1] || ""; + const targetPage = + promptSet.has("create") && options.createAccountPage + ? options.createAccountPage + : options.loginPage; + return handleRedirect(appendQuery(targetPage, queryFromURL)); } const query = ctx.query as AuthorizationQuery; diff --git a/packages/better-auth/src/plugins/oidc-provider/index.ts b/packages/better-auth/src/plugins/oidc-provider/index.ts index 997e33a4ee..59e2435015 100644 --- a/packages/better-auth/src/plugins/oidc-provider/index.ts +++ b/packages/better-auth/src/plugins/oidc-provider/index.ts @@ -102,6 +102,13 @@ export const getMetadata = ( jwks_uri: `${baseURL}/jwks`, registration_endpoint: `${baseURL}/oauth2/register`, end_session_endpoint: `${baseURL}/oauth2/endsession`, + prompt_values_supported: [ + "none", + "login", + "consent", + "select_account", + "create", + ], scopes_supported: ["openid", "profile", "email", "offline_access"], response_types_supported: ["code"], response_modes_supported: ["query"], diff --git a/packages/better-auth/src/plugins/oidc-provider/oidc.test.ts b/packages/better-auth/src/plugins/oidc-provider/oidc.test.ts index 762b61ddb0..79a7f5dba4 100644 --- a/packages/better-auth/src/plugins/oidc-provider/oidc.test.ts +++ b/packages/better-auth/src/plugins/oidc-provider/oidc.test.ts @@ -105,6 +105,7 @@ describe("oidc", async () => { plugins: [ oidcProvider({ loginPage: "/login", + createAccountPage: "/register", consentPage: "/oauth2/authorize", requirePKCE: true, getAdditionalUserInfoClaim(user) { @@ -499,6 +500,44 @@ describe("oidc", async () => { expect(redirectURI).toContain("none"); }); + it("should redirect to createAccountPage when prompt=create and user not authenticated", async ({ + expect, + }) => { + const unauthClient = createAuthClient({ + plugins: [oidcClient()], + baseURL: "http://localhost:3000", + fetchOptions: { + customFetchImpl, + }, + }); + + const authUrl = new URL( + "http://localhost:3000/api/auth/oauth2/authorize", + ); + authUrl.searchParams.set("client_id", application.clientId); + authUrl.searchParams.set( + "redirect_uri", + application.redirectUrls[0] || "", + ); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", "openid profile email"); + authUrl.searchParams.set("state", "test-state"); + authUrl.searchParams.set("prompt", "create"); + authUrl.searchParams.set("code_challenge", "test-challenge"); + authUrl.searchParams.set("code_challenge_method", "S256"); + + let redirectURI = ""; + await unauthClient.$fetch(authUrl.toString(), { + method: "GET", + onError(context) { + redirectURI = context.response.headers.get("Location") || ""; + }, + }); + + expect(redirectURI).toContain("/register"); + expect(redirectURI).toContain("prompt=create"); + }); + it("should return consent_required error when prompt=none and consent needed", async ({ expect, }) => { @@ -912,6 +951,27 @@ describe("oidc", async () => { ); }); + it("should return prompt_values_supported in metadata", async ({ + expect, + }) => { + const response: any = await serverClient.$fetch( + "/.well-known/openid-configuration", + { + method: "GET", + }, + ); + const metadata = response.data || response; + expect(metadata.prompt_values_supported).toEqual( + expect.arrayContaining([ + "none", + "login", + "consent", + "select_account", + "create", + ]), + ); + }); + it("should logout successfully without parameters", async ({ expect }) => { const response = await serverClient.$fetch("/oauth2/endsession", { method: "GET", diff --git a/packages/better-auth/src/plugins/oidc-provider/types.ts b/packages/better-auth/src/plugins/oidc-provider/types.ts index 6faefa693c..7fb8884f94 100644 --- a/packages/better-auth/src/plugins/oidc-provider/types.ts +++ b/packages/better-auth/src/plugins/oidc-provider/types.ts @@ -88,6 +88,13 @@ export interface OIDCOptions { * prompt. */ loginPage: string; + /** + * The URL to the account creation page. This is used if the client requests + * the `create` prompt. + * + * If not provided, the `loginPage` will be used. + */ + createAccountPage?: string | undefined; /** * Whether to require PKCE (proof key code exchange) or not * @@ -206,7 +213,7 @@ export interface AuthorizationQuery { */ prompt?: | (string & {}) - | ("none" | "consent" | "login" | "select_account") + | ("none" | "consent" | "login" | "select_account" | "create") | undefined; /** * The display parameter is used to specify how the authorization server displays the @@ -452,6 +459,12 @@ export interface OIDCMetadata { * @default `/oauth2/register` */ registration_endpoint: string; + /** + * Supported prompt values. + * + * When defined, it MUST include all prompt values supported by this OP. + */ + prompt_values_supported?: string[] | undefined; /** * Supported scopes. */ diff --git a/packages/better-auth/src/plugins/oidc-provider/utils/prompt.test.ts b/packages/better-auth/src/plugins/oidc-provider/utils/prompt.test.ts index 316a3144da..34f7abc8d9 100644 --- a/packages/better-auth/src/plugins/oidc-provider/utils/prompt.test.ts +++ b/packages/better-auth/src/plugins/oidc-provider/utils/prompt.test.ts @@ -60,10 +60,11 @@ describe("parsePrompt", () => { }); it("should handle all valid prompt types", () => { - const result = parsePrompt("login consent select_account"); + const result = parsePrompt("login consent select_account create"); expect(result.has("login")).toBe(true); expect(result.has("consent")).toBe(true); expect(result.has("select_account")).toBe(true); - expect(result.size).toBe(3); + expect(result.has("create")).toBe(true); + expect(result.size).toBe(4); }); }); diff --git a/packages/better-auth/src/plugins/oidc-provider/utils/prompt.ts b/packages/better-auth/src/plugins/oidc-provider/utils/prompt.ts index 67ce96631e..89c47f6e2e 100644 --- a/packages/better-auth/src/plugins/oidc-provider/utils/prompt.ts +++ b/packages/better-auth/src/plugins/oidc-provider/utils/prompt.ts @@ -1,6 +1,11 @@ import { InvalidRequest } from "../error"; -export type AuthorizePrompt = "login" | "consent" | "select_account" | "none"; +export type AuthorizePrompt = + | "login" + | "consent" + | "select_account" + | "create" + | "none"; export type AuthorizePromptSet = ReadonlySet; /** @@ -16,6 +21,7 @@ export function parsePrompt(prompt: string) { p === "login" || p === "consent" || p === "select_account" || + p === "create" || p === "none" ) { set.add(p);