feat(oidc-provider): support prompt=create

This commit is contained in:
Gustavo Valverde
2025-12-13 21:17:22 +00:00
parent 184f378ef3
commit d13cdd034e
7 changed files with 108 additions and 6 deletions
@@ -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.
@@ -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;
@@ -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"],
@@ -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",
@@ -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.
*/
@@ -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);
});
});
@@ -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<AuthorizePrompt>;
/**
@@ -16,6 +21,7 @@ export function parsePrompt(prompt: string) {
p === "login" ||
p === "consent" ||
p === "select_account" ||
p === "create" ||
p === "none"
) {
set.add(p);