Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions packages/openauth/src/provider/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,37 @@ export interface CodeProviderConfig<
* ```
*/
sendCode: (claims: Claims, code: string) => Promise<void | CodeProviderError>
/**
* 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<boolean>)
}

/**
Expand Down Expand Up @@ -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 =
| {
Expand All @@ -134,6 +166,9 @@ export type CodeProviderError =
key: string
value: string
}
| {
type: "claims_not_allowed"
}

export function CodeProvider<
Claims extends Record<string, string> = Record<string, string>,
Expand Down Expand Up @@ -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(
Expand Down
84 changes: 83 additions & 1 deletion packages/openauth/src/provider/password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,47 @@ export interface PasswordConfig {
validatePassword?:
| v1.StandardSchema
| ((password: string) => Promise<string | undefined> | 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<boolean>)
/**
* 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<boolean>
}

/**
Expand Down Expand Up @@ -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 =
| {
Expand All @@ -193,6 +235,9 @@ export type PasswordRegisterError =
type: "validation_error"
message?: string
}
| {
type: "registration_not_allowed"
}

/**
* The state of the password change flow.
Expand Down Expand Up @@ -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",
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
35 changes: 34 additions & 1 deletion packages/openauth/src/ui/code.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand All @@ -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<string, string>) => boolean | Promise<boolean>)
}

/**
Expand All @@ -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<Response> => {
if (state.type === "start") {
Expand All @@ -126,6 +156,9 @@ export function CodeUI(props: CodeUIOptions): CodeProviderOptions {
{error?.type === "invalid_claim" && (
<FormAlert message={copy.email_invalid} />
)}
{error?.type === "claims_not_allowed" && (
<FormAlert message={copy.error_claims_not_allowed} />
)}
<input type="hidden" name="action" value="request" />
<input
data-component="input"
Expand Down
43 changes: 36 additions & 7 deletions packages/openauth/src/ui/password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ const DEFAULT_COPY = {
* Error message when the user enters a password that fails validation.
*/
error_validation_error: "Password does not meet requirements.",
/**
* Error message when registration is not allowed.
*/
error_registration_not_allowed: "Registration is not allowed.",
/**
* Title of the register page.
*/
Expand Down Expand Up @@ -128,6 +132,14 @@ const DEFAULT_COPY = {
* Copy for the continue button.
*/
button_continue: "Continue",
/**
* Copy for the first-time login message.
*/
first_time_prompt: "First time logging in?",
/**
* Copy for the reset password link for first-time users.
*/
first_time_reset: "Reset your password",
} satisfies {
[key in `error_${
| PasswordLoginError["type"]
Expand All @@ -141,7 +153,10 @@ type PasswordUICopy = typeof DEFAULT_COPY
* Configure the password UI.
*/
export interface PasswordUIOptions
extends Pick<PasswordConfig, "sendCode" | "validatePassword"> {
extends Pick<
PasswordConfig,
"sendCode" | "validatePassword" | "allowRegistration" | "userExists"
> {
/**
* Custom copy for the UI.
*/
Expand All @@ -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<Response> => {
const jsx = (
<Layout>
Expand All @@ -185,16 +202,28 @@ export function PasswordUI(input: PasswordUIOptions): PasswordConfig {
/>
<button data-component="button">{copy.button_continue}</button>
<div data-component="form-footer">
<span>
{copy.register_prompt}{" "}
<a data-component="link" href="register">
{copy.register}
</a>
</span>
{input.allowRegistration !== false && (
<span>
{copy.register_prompt}{" "}
<a data-component="link" href="register">
{copy.register}
</a>
</span>
)}
<a data-component="link" href="change">
{copy.change_prompt}
</a>
</div>
{input.allowRegistration === false && input.userExists && (
<div data-component="form-footer">
<span>
{copy.first_time_prompt}{" "}
<a data-component="link" href="change">
{copy.first_time_reset}
</a>
</span>
</div>
)}
</form>
</Layout>
)
Expand Down