diff --git a/packages/openauth/src/provider/code.ts b/packages/openauth/src/provider/code.ts index e8f7b709..0dacd72f 100644 --- a/packages/openauth/src/provider/code.ts +++ b/packages/openauth/src/provider/code.ts @@ -96,6 +96,37 @@ export interface CodeProviderConfig< * ``` */ sendCode: (claims: Claims, code: string) => Promise + /** + * Controls whether claims (email, phone, etc.) are allowed to receive codes. + * + * @default true + * + * @example + * ```ts + * // Allow only company emails + * { + * allowClaims: (claims) => { + * return claims.email?.endsWith("@company.com") || false + * } + * } + * + * // Only allow existing users + * { + * allowClaims: async (claims) => { + * return await externalDB.userExists(claims.email) + * } + * } + * + * // Combined: domain + existence check + * { + * allowClaims: async (claims) => { + * return claims.email?.endsWith("@company.com") && + * await externalDB.userExists(claims.email) + * } + * } + * ``` + */ + allowClaims?: boolean | ((claims: Claims) => boolean | Promise) } /** @@ -124,6 +155,7 @@ export type CodeProviderState = * | ----- | ----------- | * | `invalid_code` | The code is invalid. | * | `invalid_claim` | The _claim_, email or phone number, is invalid. | + * | `claims_not_allowed` | The claims are not allowed. | */ export type CodeProviderError = | { @@ -134,6 +166,9 @@ export type CodeProviderError = key: string value: string } + | { + type: "claims_not_allowed" + } export function CodeProvider< Claims extends Record = Record, @@ -175,6 +210,22 @@ export function CodeProvider< if (action === "request" || action === "resend") { const claims = Object.fromEntries(fd) as Claims delete claims.action + + // Validate claims are allowed + if (config.allowClaims !== undefined) { + let allowed = true + if (typeof config.allowClaims === "boolean") { + allowed = config.allowClaims + } else if (typeof config.allowClaims === "function") { + allowed = await config.allowClaims(claims) + } + if (!allowed) { + return transition(c, { type: "start" }, fd, { + type: "claims_not_allowed", + }) + } + } + const err = await config.sendCode(claims, code) if (err) return transition(c, { type: "start" }, fd, err) return transition( diff --git a/packages/openauth/src/provider/password.ts b/packages/openauth/src/provider/password.ts index 850af8a3..2236c5db 100644 --- a/packages/openauth/src/provider/password.ts +++ b/packages/openauth/src/provider/password.ts @@ -141,6 +141,47 @@ export interface PasswordConfig { validatePassword?: | v1.StandardSchema | ((password: string) => Promise | string | undefined) + /** + * Controls whether new user registration is allowed. + * + * This allows implementing sign-up restrictions, domain validation, + * or completely disable sign-ups. + * + * @default true + * + * @example + * ```ts + * // Disable all registrations + * { + * allowRegistration: false + * } + * + * // Allow only specific email domains + * { + * allowRegistration: (email) => { + * return email.endsWith("@company.com") + * } + * } + * ``` + */ + allowRegistration?: boolean | ((email: string) => boolean | Promise) + /** + * Callback to check if a user exists in an external system. + * + * Used when allowRegistration is false to validate existing users + * during password reset flow. + * + * @example + * ```ts + * { + * userExists: async (email) => { + * // Check your external user database + * return await externalDB.userExists(email) + * } + * } + * ``` + */ + userExists?: (email: string) => boolean | Promise } /** @@ -172,6 +213,7 @@ export type PasswordRegisterState = * | `invalid_code` | The code is invalid. | * | `invalid_password` | The password is invalid. | * | `password_mismatch` | The passwords do not match. | + * | `registration_not_allowed` | Registration is not allowed for this email. | */ export type PasswordRegisterError = | { @@ -193,6 +235,9 @@ export type PasswordRegisterError = type: "validation_error" message?: string } + | { + type: "registration_not_allowed" + } /** * The state of the password change flow. @@ -311,6 +356,9 @@ export function PasswordProvider( }) routes.get("/register", async (c) => { + if (config.allowRegistration === false) { + return c.redirect(getRelativeUrl(c, "./authorize"), 302) + } const state: PasswordRegisterState = { type: "start", } @@ -341,6 +389,20 @@ export function PasswordProvider( const password = fd.get("password")?.toString() const repeat = fd.get("repeat")?.toString() if (!email) return transition(provider, { type: "invalid_email" }) + + // Check if registration is allowed for this email + if (config.allowRegistration !== undefined) { + let allowed = true + if (typeof config.allowRegistration === "boolean") { + allowed = config.allowRegistration + } else if (typeof config.allowRegistration === "function") { + allowed = await config.allowRegistration(email) + } + if (!allowed) { + return transition(provider, { type: "registration_not_allowed" }) + } + } + if (!password) return transition(provider, { type: "invalid_password" }) if (password !== repeat) @@ -481,7 +543,27 @@ export function PasswordProvider( provider.email, "password", ]) - if (!existing) return c.redirect(provider.redirect, 302) + + // If user doesn't exist in storage, check if they exist in external system + // Only do this when registrations are disabled (allowRegistration === false) + if (!existing) { + if (config.allowRegistration === false && config.userExists) { + if (await config.userExists(provider.email)) { + // Create user in storage so password can be set + await Storage.set( + ctx.storage, + ["email", provider.email, "password"], + Math.random().toString(36), // Temporary placeholder that will be replaced + ) + } else { + // User doesn't exist in external system, redirect to login + return c.redirect(provider.redirect, 302) + } + } else { + // Registration allowed or no userExists callback, use default behavior (redirect to login) + return c.redirect(provider.redirect, 302) + } + } const password = fd.get("password")?.toString() const repeat = fd.get("repeat")?.toString() diff --git a/packages/openauth/src/ui/code.tsx b/packages/openauth/src/ui/code.tsx index 5ed77392..5191024c 100644 --- a/packages/openauth/src/ui/code.tsx +++ b/packages/openauth/src/ui/code.tsx @@ -70,12 +70,16 @@ const DEFAULT_COPY = { * Copy for the resend button. */ code_resend: "Resend", + /** + * Error message when claims are not allowed. + */ + error_claims_not_allowed: "Access not allowed.", } export type CodeUICopy = typeof DEFAULT_COPY /** - * Configure the password UI. + * Configure the code UI. */ export interface CodeUIOptions { /** @@ -101,6 +105,31 @@ export interface CodeUIOptions { * @default "email" */ mode?: "email" | "phone" + /** + * Controls whether claims (email, phone, etc.) are allowed to receive codes. + * + * @default true + * + * @example + * ```ts + * // Allow only company emails + * { + * allowClaims: (claims) => { + * return claims.email?.endsWith("@company.com") || false + * } + * } + * + * // Only allow existing users + * { + * allowClaims: async (claims) => { + * return await externalDB.userExists(claims.email) + * } + * } + * ``` + */ + allowClaims?: + | boolean + | ((claims: Record) => boolean | Promise) } /** @@ -117,6 +146,7 @@ export function CodeUI(props: CodeUIOptions): CodeProviderOptions { return { sendCode: props.sendCode, + allowClaims: props.allowClaims, length: 6, request: async (_req, state, _form, error): Promise => { if (state.type === "start") { @@ -126,6 +156,9 @@ export function CodeUI(props: CodeUIOptions): CodeProviderOptions { {error?.type === "invalid_claim" && ( )} + {error?.type === "claims_not_allowed" && ( + + )} { + extends Pick< + PasswordConfig, + "sendCode" | "validatePassword" | "allowRegistration" | "userExists" + > { /** * Custom copy for the UI. */ @@ -160,6 +175,8 @@ export function PasswordUI(input: PasswordUIOptions): PasswordConfig { return { validatePassword: input.validatePassword, sendCode: input.sendCode, + allowRegistration: input.allowRegistration, + userExists: input.userExists, login: async (_req, form, error): Promise => { const jsx = ( @@ -185,16 +202,28 @@ export function PasswordUI(input: PasswordUIOptions): PasswordConfig { />
- - {copy.register_prompt}{" "} - - {copy.register} - - + {input.allowRegistration !== false && ( + + {copy.register_prompt}{" "} + + {copy.register} + + + )} {copy.change_prompt}
+ {input.allowRegistration === false && input.userExists && ( +
+ + {copy.first_time_prompt}{" "} + + {copy.first_time_reset} + + +
+ )}
)