From c6b03fe2ffa328f67789ec360f4f431014bcd70c Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Thu, 25 Jun 2026 16:44:14 +0530 Subject: [PATCH 1/9] feat: add types and error classes for passwordless DB connections --- src/client/passwordless/index.ts | 23 ++++++ src/errors/index.ts | 5 ++ src/errors/passwordless-db-errors.ts | 81 +++++++++++++++++++ .../server-passwordless-client.ts | 23 ++++++ src/types/index.ts | 7 +- src/types/passwordless-db.ts | 77 ++++++++++++++++++ src/types/passwordless.ts | 55 +++++++++++++ 7 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 src/errors/passwordless-db-errors.ts create mode 100644 src/types/passwordless-db.ts diff --git a/src/client/passwordless/index.ts b/src/client/passwordless/index.ts index 07bd0daf7..32872964d 100644 --- a/src/client/passwordless/index.ts +++ b/src/client/passwordless/index.ts @@ -4,6 +4,10 @@ import { } from "../../errors/index.js"; import type { PasswordlessClient, + PasswordlessDbChallenge, + PasswordlessDbChallengeEmailOptions, + PasswordlessDbChallengePhoneOptions, + PasswordlessDbGetTokenOptions, PasswordlessStartOptions, PasswordlessVerifyOptions } from "../../types/index.js"; @@ -136,6 +140,25 @@ class ClientPasswordlessClient implements PasswordlessClient { ); } } + + async challengeWithEmail( + options: PasswordlessDbChallengeEmailOptions + ): Promise { + void options; + throw new Error("Not implemented"); + } + + async challengeWithPhoneNumber( + options: PasswordlessDbChallengePhoneOptions + ): Promise { + void options; + throw new Error("Not implemented"); + } + + async loginWithOtp(options: PasswordlessDbGetTokenOptions): Promise { + void options; + throw new Error("Not implemented"); + } } /** diff --git a/src/errors/index.ts b/src/errors/index.ts index 91e3f94ec..6e3b3abb3 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -58,6 +58,11 @@ export { type PasswordlessApiErrorResponse } from "./passwordless-errors.js"; +export { + PasswordlessDbChallengeError, + PasswordlessDbGetTokenError +} from "./passwordless-db-errors.js"; + // MCD (Multiple Custom Domains) error classes export { DomainResolutionError, diff --git a/src/errors/passwordless-db-errors.ts b/src/errors/passwordless-db-errors.ts new file mode 100644 index 000000000..bcf1cecff --- /dev/null +++ b/src/errors/passwordless-db-errors.ts @@ -0,0 +1,81 @@ +import { type PasswordlessApiErrorResponse } from "./passwordless-errors.js"; +import { SdkError } from "./sdk-error.js"; + +abstract class PasswordlessDbError extends SdkError { + public abstract readonly error: string; + public abstract readonly error_description: string; + + toJSON(): { error: string; error_description: string } { + return { + error: this.error, + error_description: this.error_description + }; + } + + public abstract get code(): string; +} + +/** + * Thrown when a `POST /otp/challenge` request fails. + * + * Common causes: + * - OTP grant type not enabled on the application + * - Connection is not a database connection (`invalid_connection`) + * - OTP not configured on the connection (`invalid_connection`) + * - Phone provider not configured when using phone OTP + * - Missing or invalid identifier + */ +export class PasswordlessDbChallengeError extends PasswordlessDbError { + public readonly error: string; + public readonly error_description: string; + public readonly cause?: PasswordlessApiErrorResponse; + + public get code(): string { + return "passwordless_challenge_error"; + } + + constructor( + error: string, + error_description: string, + cause?: PasswordlessApiErrorResponse + ) { + super(error_description); + this.name = "PasswordlessDbChallengeError"; + this.error = error; + this.error_description = error_description; + this.cause = cause; + Object.setPrototypeOf(this, PasswordlessDbChallengeError.prototype); + } +} + +/** + * Thrown when a `POST /oauth/token` OTP exchange fails. + * + * Common causes: + * - Invalid or expired OTP code (`invalid_request`) + * - Wrong or expired `auth_session` (`invalid_request`) + * - Blocked user or signup disabled for a non-existent user (`invalid_request`) + * - Discovery failure (`discovery_error`) + */ +export class PasswordlessDbGetTokenError extends PasswordlessDbError { + public readonly error: string; + public readonly error_description: string; + public readonly cause?: PasswordlessApiErrorResponse; + + public get code(): string { + return "passwordless_login_error"; + } + + constructor( + error: string, + error_description: string, + cause?: PasswordlessApiErrorResponse + ) { + super(error_description); + this.name = "PasswordlessDbGetTokenError"; + this.error = error; + this.error_description = error_description; + this.cause = cause; + Object.setPrototypeOf(this, PasswordlessDbGetTokenError.prototype); + } +} diff --git a/src/server/passwordless/server-passwordless-client.ts b/src/server/passwordless/server-passwordless-client.ts index d75b3cd5c..561f9e0e8 100644 --- a/src/server/passwordless/server-passwordless-client.ts +++ b/src/server/passwordless/server-passwordless-client.ts @@ -3,6 +3,10 @@ import { NextRequest, NextResponse } from "next/server.js"; import type { PasswordlessClient, + PasswordlessDbChallenge, + PasswordlessDbChallengeEmailOptions, + PasswordlessDbChallengePhoneOptions, + PasswordlessDbGetTokenOptions, PasswordlessStartOptions, PasswordlessVerifyOptions } from "../../types/index.js"; @@ -167,4 +171,23 @@ export class ServerPasswordlessClient implements PasswordlessClient { ); } } + + async challengeWithEmail( + options: PasswordlessDbChallengeEmailOptions + ): Promise { + void options; + throw new Error("Not implemented"); + } + + async challengeWithPhoneNumber( + options: PasswordlessDbChallengePhoneOptions + ): Promise { + void options; + throw new Error("Not implemented"); + } + + async loginWithOtp(options: PasswordlessDbGetTokenOptions): Promise { + void options; + throw new Error("Not implemented"); + } } diff --git a/src/types/index.ts b/src/types/index.ts index d6dc7db3d..cc6fd58e8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -267,7 +267,12 @@ export { PasswordlessVerifyEmailOptions, PasswordlessVerifySmsOptions, PasswordlessVerifyTokenResponse, - GRANT_TYPE_PASSWORDLESS_OTP + GRANT_TYPE_PASSWORDLESS_OTP, + PasswordlessDbChallenge, + PasswordlessDbChallengeEmailOptions, + PasswordlessDbChallengePhoneOptions, + PasswordlessDbDeliveryMethod, + PasswordlessDbGetTokenOptions } from "./passwordless.js"; export { diff --git a/src/types/passwordless-db.ts b/src/types/passwordless-db.ts new file mode 100644 index 000000000..93eb156bd --- /dev/null +++ b/src/types/passwordless-db.ts @@ -0,0 +1,77 @@ +/** + * Delivery method for phone OTP. + */ +export type PasswordlessDbDeliveryMethod = "text" | "voice"; + +/** + * Options to request a passwordless OTP challenge for a database connection user + * identified by email address. + * + * The challenge always returns successfully regardless of whether the user exists + * (user-enumeration prevention). If the user does not exist and `allowSignup` is + * `false`, or if the user is blocked, the returned `auth_session` will be + * non-functional and `loginWithOtp` will fail with `invalid_request`. + * + * A successful challenge does not guarantee an OTP was delivered. + */ +export interface PasswordlessDbChallengeEmailOptions { + /** The email address to send the OTP to. */ + email: string; + /** The database connection name configured with `email_otp`. */ + connection: string; + /** + * Whether to allow signup for users who do not yet exist in the connection. + * Defaults to `false`. When `false`, non-existent users receive a silent + * fake `auth_session` (no OTP sent) for GDPR compliance. + */ + allowSignup?: boolean; +} + +/** + * Options to request a passwordless OTP challenge for a database connection user + * identified by phone number. + * + * Same 200-always contract as {@link PasswordlessDbChallengeEmailOptions}. + */ +export interface PasswordlessDbChallengePhoneOptions { + /** The phone number to send the OTP to, in E.164 format (e.g. `'+14155550100'`). */ + phoneNumber: string; + /** The database connection name configured with `phone_otp`. */ + connection: string; + /** + * OTP delivery channel. + * - `'text'` — SMS (default) + * - `'voice'` — voice call + */ + deliveryMethod?: PasswordlessDbDeliveryMethod; + /** + * Whether to allow signup for users who do not yet exist in the connection. + * Defaults to `false`. + */ + allowSignup?: boolean; +} + +/** + * The opaque challenge token returned by `POST /otp/challenge`. + * + * Treat `authSession` as a black box — never parse, log, or persist it beyond + * the in-flight OTP verification flow. + */ +export interface PasswordlessDbChallenge { + /** Opaque session handle returned by Auth0. Pass directly to `loginWithOtp`. */ + authSession: string; +} + +/** + * Options to exchange a challenge `auth_session` and user-entered OTP for tokens. + * + * Throws `PasswordlessLoginError` with `error: "invalid_request"` if the + * `auth_session` was non-functional (blocked user, signup disabled for a + * non-existent user, wrong OTP, or expired session). + */ +export interface PasswordlessDbGetTokenOptions { + /** The opaque `authSession` returned by `challengeWithEmail` or `challengeWithPhoneNumber`. */ + authSession: string; + /** The one-time password entered by the user. */ + otp: string; +} diff --git a/src/types/passwordless.ts b/src/types/passwordless.ts index 6741f12d0..0b5fd4ae6 100644 --- a/src/types/passwordless.ts +++ b/src/types/passwordless.ts @@ -1,3 +1,18 @@ +import type { + PasswordlessDbChallenge, + PasswordlessDbChallengeEmailOptions, + PasswordlessDbChallengePhoneOptions, + PasswordlessDbGetTokenOptions +} from "./passwordless-db.js"; + +export type { + PasswordlessDbChallenge, + PasswordlessDbChallengeEmailOptions, + PasswordlessDbChallengePhoneOptions, + PasswordlessDbDeliveryMethod, + PasswordlessDbGetTokenOptions +} from "./passwordless-db.js"; + /** * Grant type for passwordless OTP verification. * Used to exchange a one-time password for access/refresh tokens. @@ -150,4 +165,44 @@ export interface PasswordlessClient { * @param options - Connection type, user identifier, and OTP code. */ verify(options: PasswordlessVerifyOptions): Promise; + + /** + * Request a one-time password for a **database connection** user identified by email. + * + * Calls `POST /otp/challenge` and returns an opaque `authSession` token. The challenge + * always returns successfully regardless of whether the user exists + * (user-enumeration prevention). If the user does not exist and `allowSignup` is + * `false`, or if the user is blocked, the returned `authSession` will be non-functional + * and `loginWithOtp` will fail with `invalid_request`. + * + * A successful response does not guarantee an OTP was delivered. + * + * @param options - Database connection name, email address, and optional signup flag. + */ + challengeWithEmail( + options: PasswordlessDbChallengeEmailOptions + ): Promise; + + /** + * Request a one-time password for a **database connection** user identified by + * phone number. + * + * Same 200-always contract as `challengeWithEmail`. + * + * @param options - Database connection name, phone number, optional delivery method, and signup flag. + */ + challengeWithPhoneNumber( + options: PasswordlessDbChallengePhoneOptions + ): Promise; + + /** + * Exchange an `authSession` and user-entered OTP for tokens and establish a session. + * + * Throws `PasswordlessLoginError` with `error: "invalid_request"` if the + * `authSession` was non-functional (blocked user, signup disabled for a non-existent + * user, wrong OTP, or expired session). + * + * @param options - The `authSession` from a prior challenge call and the user's OTP. + */ + loginWithOtp(options: PasswordlessDbGetTokenOptions): Promise; } From 9646453355016cfe506698431f394c5f19c29d30 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Thu, 25 Jun 2026 17:25:17 +0530 Subject: [PATCH 2/9] feat: implement passwordless DB OTP challenge and token exchange (server) --- src/server/auth-client.ts | 367 ++++++++++++++++++ src/server/client.ts | 6 + src/server/dynamic-base-url.test.ts | 2 + .../server-passwordless-client.ts | 109 +++++- src/test/defaults.ts | 6 + src/test/mcd-test-fixtures.ts | 2 + 6 files changed, 485 insertions(+), 7 deletions(-) diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index ffdf32653..4a8ab10ff 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -41,6 +41,8 @@ import { PasskeyEnrollmentVerifyError, PasskeyGetTokenError, PasskeyRegisterError, + PasswordlessDbChallengeError, + PasswordlessDbGetTokenError, PasswordlessStartError, PasswordlessVerifyError, SdkError @@ -87,6 +89,10 @@ import { PasskeyGetTokenOptions, PasskeyRegisterOptions, PasskeyRegisterResponse, + PasswordlessDbChallenge, + PasswordlessDbChallengeEmailOptions, + PasswordlessDbChallengePhoneOptions, + PasswordlessDbGetTokenOptions, PasswordlessStartOptions, PasswordlessVerifyOptions, PasswordlessVerifyTokenResponse, @@ -256,6 +262,8 @@ export interface Routes { mfaAssociate: string; passwordlessStart: string; passwordlessVerify: string; + passwordlessDbOtpChallenge: string; + passwordlessDbGetToken: string; passkeyRegister: string; passkeyChallenge: string; passkeyGetToken: string; @@ -692,6 +700,16 @@ export class AuthClient { sanitizedPathname === this.routes.passwordlessVerify ) { return this.handlePasswordlessVerify(req); + } else if ( + method === "POST" && + sanitizedPathname === this.routes.passwordlessDbOtpChallenge + ) { + return this.handlePasswordlessDbOtpChallenge(req); + } else if ( + method === "POST" && + sanitizedPathname === this.routes.passwordlessDbGetToken + ) { + return this.handlePasswordlessDbGetToken(req); } else if ( method === "POST" && sanitizedPathname === this.routes.passkeyRegister @@ -1829,6 +1847,156 @@ export class AuthClient { } } + /** + * Route: POST /auth/passwordless/otp/challenge + * Requests an OTP challenge against a database connection via /otp/challenge. + * + * Body: { email, connection, allowSignup? } + * { phoneNumber, connection, deliveryMethod?, allowSignup? } + * + * Response: 200 { authSession: "" } + * Error: 400 + { error, error_description } + */ + async handlePasswordlessDbOtpChallenge( + req: NextRequest + ): Promise { + try { + const body = await parseJsonBody(req); + const bodyRecord = body as Record; + + const connection = validateStringFieldAndThrow( + bodyRecord.connection, + "connection" + ); + + const allowSignup = + typeof bodyRecord.allowSignup === "boolean" + ? bodyRecord.allowSignup + : false; + + let challenge: PasswordlessDbChallenge; + + if (typeof bodyRecord.email === "string" && bodyRecord.email) { + challenge = await this.passwordlessDbOtpChallenge({ + email: bodyRecord.email, + connection, + allowSignup + }); + } else if ( + typeof bodyRecord.phoneNumber === "string" && + bodyRecord.phoneNumber + ) { + const deliveryMethod = + bodyRecord.deliveryMethod === "voice" ? "voice" : "text"; + challenge = await this.passwordlessDbOtpChallenge({ + phoneNumber: bodyRecord.phoneNumber, + connection, + allowSignup, + deliveryMethod + }); + } else { + return NextResponse.json( + { + error: "missing_identifier", + error_description: "Either email or phoneNumber is required" + }, + { status: 400 } + ); + } + + return NextResponse.json({ authSession: challenge.authSession }); + } catch (e) { + if (e instanceof PasswordlessDbChallengeError) { + if (e.error === "unexpected_error") { + return NextResponse.json( + { + error: "server_error", + error_description: "Internal server error" + }, + { status: 500 } + ); + } + return NextResponse.json(e.toJSON(), { status: 400 }); + } + if (e instanceof SdkError) { + return NextResponse.json( + { error: e.code, error_description: e.message }, + { status: 400 } + ); + } + return NextResponse.json( + { error: "server_error", error_description: "Internal server error" }, + { status: 500 } + ); + } + } + + /** + * Route: POST /auth/passwordless/otp/token + * Exchanges an auth_session + OTP for tokens and establishes a session. + * + * Body: { authSession, otp } + * + * Response: 200 { success: true } + Set-Cookie + * Error: 400/403/500 + { error, error_description } + */ + async handlePasswordlessDbGetToken(req: NextRequest): Promise { + try { + const body = await parseJsonBody(req); + const bodyRecord = body as Record; + + const authSession = validateStringFieldAndThrow( + bodyRecord.authSession, + "authSession" + ); + const otp = validateStringFieldAndThrow(bodyRecord.otp, "otp"); + + const tokenResponse = await this.passwordlessDbGetToken({ + authSession, + otp + }); + + const res = NextResponse.json({ success: true }); + await this.createSessionFromPasswordlessVerify( + tokenResponse, + req.cookies, + res.cookies + ); + addCacheControlHeadersForSession(res); + return res; + } catch (e) { + if (e instanceof MfaRequiredError) { + return this.#createMfaRequiredResponse(req, null, e); + } + if (e instanceof PasswordlessDbGetTokenError) { + const serverErrorCodes = new Set([ + "discovery_error", + "unexpected_error" + ]); + if (serverErrorCodes.has(e.error)) { + return NextResponse.json( + { + error: "server_error", + error_description: "Internal server error" + }, + { status: 500 } + ); + } + return NextResponse.json(e.toJSON(), { status: 403 }); + } + if (e instanceof SdkError) { + return NextResponse.json( + { error: e.code, error_description: e.message }, + { status: 400 } + ); + } + return NextResponse.json( + { error: "server_error", error_description: "Internal server error" }, + { status: 500 } + ); + } + } + async handleAccessToken(req: NextRequest): Promise { const { error: sessionError, session } = await this.getSessionWithDomainCheck(req.cookies); @@ -5163,6 +5331,205 @@ export class AuthClient { }; } + /** + * Requests an OTP challenge for a database connection via `POST /otp/challenge`. + * + * The challenge always returns 200 regardless of whether the user exists + * (user-enumeration prevention). If the user is blocked or signup is disabled + * for a non-existent user, the returned auth_session will be non-functional + * and passwordlessDbGetToken will fail with invalid_request. + * + * @param options - Email or phone challenge options including connection name. + * @returns Opaque PasswordlessDbChallenge containing auth_session. + * @throws {PasswordlessDbChallengeError} On Auth0 API failure. + */ + async passwordlessDbOtpChallenge( + options: + | PasswordlessDbChallengeEmailOptions + | PasswordlessDbChallengePhoneOptions + ): Promise { + const url = new URL("/otp/challenge", this.issuer).toString(); + const httpOptions = this.httpOptions(); + httpOptions.headers.set("Content-Type", "application/json"); + + const body: Record = { + client_id: this.clientMetadata.client_id, + connection: options.connection, + allow_signup: options.allowSignup ?? false + }; + + if (this.clientSecret) { + body.client_secret = this.clientSecret; + } + + if ("email" in options) { + body.email = options.email; + } else { + body.phone_number = options.phoneNumber; + if (options.deliveryMethod) { + body.delivery_method = options.deliveryMethod; + } + } + + try { + const response = await this.fetch(url, { + method: "POST", + body: JSON.stringify(body), + ...httpOptions + }); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({ + error: "unknown_error", + error_description: "Failed to request OTP challenge" + })); + throw new PasswordlessDbChallengeError( + errorBody.error || "unknown_error", + errorBody.error_description || "Failed to request OTP challenge", + errorBody.error ? errorBody : undefined + ); + } + + const data = await response.json(); + return { authSession: data.auth_session }; + } catch (e) { + if (e instanceof PasswordlessDbChallengeError) throw e; + throw new PasswordlessDbChallengeError( + "unexpected_error", + "Unexpected error during OTP challenge", + undefined + ); + } + } + + /** + * Exchanges an auth_session and OTP for tokens via `POST /oauth/token`. + * Uses grant type `http://auth0.com/oauth/grant-type/passwordless/otp`. + * DPoP is applied when enabled. + * + * @param options - The opaque auth_session and user-entered OTP. + * @returns Token response from Auth0. + * @throws {PasswordlessDbGetTokenError} On Auth0 API failure or discovery failure. + */ + async passwordlessDbGetToken( + options: PasswordlessDbGetTokenOptions + ): Promise { + await this.ensureDpopValidated(); + + const [discoveryError, authorizationServerMetadata] = + await this.discoverAuthorizationServerMetadata(); + + if (discoveryError) { + throw new PasswordlessDbGetTokenError( + "discovery_error", + "Failed to discover authorization server metadata", + undefined + ); + } + + const scope = + getScopeForAudience( + this.authorizationParameters.scope, + this.authorizationParameters.audience + ) || DEFAULT_SCOPES; + + const params = new URLSearchParams(); + params.append("auth_session", options.authSession); + params.append("otp", options.otp); + params.append("scope", scope); + + if (this.clientSecret) { + params.append("client_secret", this.clientSecret); + } + + if (this.authorizationParameters.audience) { + params.append( + "audience", + this.authorizationParameters.audience as string + ); + } + + const dpopHandle = + this.useDPoP && this.dpopKeyPair + ? oauth.DPoP(this.clientMetadata, this.dpopKeyPair) + : undefined; + + let tokenEndpointResponse: oauth.TokenEndpointResponse; + try { + tokenEndpointResponse = await withDPoPNonceRetry( + async () => { + const httpResponse = await oauth.genericTokenEndpointRequest( + authorizationServerMetadata, + this.clientMetadata, + await this.getClientAuth(), + GRANT_TYPE_PASSWORDLESS_OTP, + params, + { + ...this.httpOptions(), + [oauth.customFetch]: this.fetch, + [oauth.allowInsecureRequests]: this.allowInsecureRequests, + ...(dpopHandle && { DPoP: dpopHandle }) + } + ); + return oauth.processGenericTokenEndpointResponse( + authorizationServerMetadata, + this.clientMetadata, + httpResponse + ); + }, + { + isDPoPEnabled: !!(this.useDPoP && this.dpopKeyPair), + ...this.dpopOptions?.retry + } + ); + } catch (err: any) { + if (err?.code === oauth.JWT_CLAIM_COMPARISON) { + const claim = (err.cause as any)?.claim; + if (claim === "iss") { + throw new PasswordlessDbGetTokenError( + "invalid_issuer", + "ID token issuer mismatch. Check AUTH0_DOMAIN configuration." + ); + } + if (claim === "aud") { + throw new PasswordlessDbGetTokenError( + "invalid_audience", + "ID token audience mismatch. Check AUTH0_CLIENT_ID configuration." + ); + } + } + + const oauthErr = await extractOAuthErrorDetails(err); + await this.#throwIfMfaRequired( + err, + oauthErr, + this.authorizationParameters.audience as string | undefined, + scope + ); + throw new PasswordlessDbGetTokenError( + oauthErr.error || "unknown_error", + oauthErr.error_description || + err.message || + "Passwordless DB token exchange failed", + oauthErr.error + ? { + error: oauthErr.error, + error_description: oauthErr.error_description ?? "" + } + : undefined + ); + } + + return { + access_token: tokenEndpointResponse.access_token, + refresh_token: tokenEndpointResponse.refresh_token, + id_token: tokenEndpointResponse.id_token, + token_type: normalizeTokenType(tokenEndpointResponse.token_type), + scope: tokenEndpointResponse.scope, + expires_in: Number(tokenEndpointResponse.expires_in) + }; + } + /** * Handles proxying requests to a target URL with authentication. * diff --git a/src/server/client.ts b/src/server/client.ts index 0f3325481..80fb8610d 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -679,6 +679,12 @@ export class Auth0Client { passwordlessVerify: process.env.NEXT_PUBLIC_PASSWORDLESS_VERIFY_ROUTE || "/auth/passwordless/verify", + passwordlessDbOtpChallenge: + process.env.NEXT_PUBLIC_PASSWORDLESS_DB_OTP_CHALLENGE_ROUTE || + "/auth/passwordless/otp/challenge", + passwordlessDbGetToken: + process.env.NEXT_PUBLIC_PASSWORDLESS_DB_GET_TOKEN_ROUTE || + "/auth/passwordless/otp/token", passkeyRegister: process.env.NEXT_PUBLIC_PASSKEY_REGISTER_ROUTE || "/auth/passkey/register", diff --git a/src/server/dynamic-base-url.test.ts b/src/server/dynamic-base-url.test.ts index 406c8e87f..cb55ade5d 100644 --- a/src/server/dynamic-base-url.test.ts +++ b/src/server/dynamic-base-url.test.ts @@ -25,6 +25,8 @@ const defaultRoutes = { mfaAssociate: "/auth/mfa/associate", passwordlessStart: "/auth/passwordless/start", passwordlessVerify: "/auth/passwordless/verify", + passwordlessDbOtpChallenge: "/auth/passwordless/otp/challenge", + passwordlessDbGetToken: "/auth/passwordless/otp/token", passkeyRegister: "/auth/passkey/register", passkeyChallenge: "/auth/passkey/challenge", passkeyGetToken: "/auth/passkey/get-token", diff --git a/src/server/passwordless/server-passwordless-client.ts b/src/server/passwordless/server-passwordless-client.ts index 561f9e0e8..33708d344 100644 --- a/src/server/passwordless/server-passwordless-client.ts +++ b/src/server/passwordless/server-passwordless-client.ts @@ -172,22 +172,117 @@ export class ServerPasswordlessClient implements PasswordlessClient { } } + /** + * Request an OTP challenge for a database connection user identified by email. + * App Router overload — reads headers from `next/headers`. + */ + async challengeWithEmail( + options: PasswordlessDbChallengeEmailOptions + ): Promise; + + /** + * Request an OTP challenge for a database connection user identified by email. + * Pages Router overload — explicit req for MCD domain resolution. + */ async challengeWithEmail( + req: NextRequest, options: PasswordlessDbChallengeEmailOptions + ): Promise; + + async challengeWithEmail( + arg1: PasswordlessDbChallengeEmailOptions | NextRequest, + arg2?: PasswordlessDbChallengeEmailOptions ): Promise { - void options; - throw new Error("Not implemented"); + if (arg1 instanceof NextRequest) { + if (!arg2) { + throw new TypeError( + "challengeWithEmail(req, options): Both arguments required for Pages Router" + ); + } + const authClient = await this.getAuthClient(arg1); + return authClient.passwordlessDbOtpChallenge(arg2); + } + const authClient = await this.getAuthClient(); + return authClient.passwordlessDbOtpChallenge(arg1); } + /** + * Request an OTP challenge for a database connection user identified by phone number. + * App Router overload — reads headers from `next/headers`. + */ async challengeWithPhoneNumber( options: PasswordlessDbChallengePhoneOptions + ): Promise; + + /** + * Request an OTP challenge for a database connection user identified by phone number. + * Pages Router overload — explicit req for MCD domain resolution. + */ + async challengeWithPhoneNumber( + req: NextRequest, + options: PasswordlessDbChallengePhoneOptions + ): Promise; + + async challengeWithPhoneNumber( + arg1: PasswordlessDbChallengePhoneOptions | NextRequest, + arg2?: PasswordlessDbChallengePhoneOptions ): Promise { - void options; - throw new Error("Not implemented"); + if (arg1 instanceof NextRequest) { + if (!arg2) { + throw new TypeError( + "challengeWithPhoneNumber(req, options): Both arguments required for Pages Router" + ); + } + const authClient = await this.getAuthClient(arg1); + return authClient.passwordlessDbOtpChallenge(arg2); + } + const authClient = await this.getAuthClient(); + return authClient.passwordlessDbOtpChallenge(arg1); } - async loginWithOtp(options: PasswordlessDbGetTokenOptions): Promise { - void options; - throw new Error("Not implemented"); + /** + * Exchange an auth_session + OTP for tokens and establish a session. + * App Router overload — reads and writes cookies from `next/headers`. + */ + async loginWithOtp(options: PasswordlessDbGetTokenOptions): Promise; + + /** + * Exchange an auth_session + OTP for tokens and establish a session. + * Pages Router overload — explicit req/res for cookie handling. + */ + async loginWithOtp( + req: NextRequest, + res: NextResponse, + options: PasswordlessDbGetTokenOptions + ): Promise; + + async loginWithOtp( + arg1: PasswordlessDbGetTokenOptions | NextRequest, + arg2?: NextResponse, + arg3?: PasswordlessDbGetTokenOptions + ): Promise { + if (arg1 instanceof NextRequest) { + if (!arg2 || !arg3) { + throw new TypeError( + "loginWithOtp(req, res, options): All three arguments required for Pages Router" + ); + } + const authClient = await this.getAuthClient(arg1); + const tokenResponse = await authClient.passwordlessDbGetToken(arg3); + await authClient.createSessionFromPasswordlessVerify( + tokenResponse, + arg1.cookies, + arg2.cookies + ); + return; + } + const authClient = await this.getAuthClient(); + const tokenResponse = await authClient.passwordlessDbGetToken(arg1); + const cookiesLib = await getCookies(); + await authClient.createSessionFromPasswordlessVerify( + tokenResponse, + cookiesLib as any, + cookiesLib as any + ); } } diff --git a/src/test/defaults.ts b/src/test/defaults.ts index 9426ba680..c794ed6c4 100644 --- a/src/test/defaults.ts +++ b/src/test/defaults.ts @@ -27,6 +27,12 @@ export function getDefaultRoutes(): Routes { passwordlessVerify: process.env.NEXT_PUBLIC_PASSWORDLESS_VERIFY_ROUTE || "/auth/passwordless/verify", + passwordlessDbOtpChallenge: + process.env.NEXT_PUBLIC_PASSWORDLESS_DB_OTP_CHALLENGE_ROUTE || + "/auth/passwordless/otp/challenge", + passwordlessDbGetToken: + process.env.NEXT_PUBLIC_PASSWORDLESS_DB_GET_TOKEN_ROUTE || + "/auth/passwordless/otp/token", passkeyRegister: process.env.NEXT_PUBLIC_PASSKEY_REGISTER_ROUTE || "/auth/passkey/register", diff --git a/src/test/mcd-test-fixtures.ts b/src/test/mcd-test-fixtures.ts index b874f17ff..c778fa53c 100644 --- a/src/test/mcd-test-fixtures.ts +++ b/src/test/mcd-test-fixtures.ts @@ -50,6 +50,8 @@ export const TEST_DEFAULT_ROUTES: Routes = { mfaAssociate: "/auth/mfa/associate", passwordlessStart: "/auth/passwordless/start", passwordlessVerify: "/auth/passwordless/verify", + passwordlessDbOtpChallenge: "/auth/passwordless/otp/challenge", + passwordlessDbGetToken: "/auth/passwordless/otp/token", passkeyRegister: "/auth/passkey/register", passkeyChallenge: "/auth/passkey/challenge", passkeyGetToken: "/auth/passkey/get-token", From ae015f58e5306998479bb4ec4a8c677d9c655131 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Thu, 25 Jun 2026 17:29:31 +0530 Subject: [PATCH 3/9] feat: implement passwordless DB OTP challenge and token exchange (client) --- src/client/passwordless/index.ts | 113 +++++- .../passwordless-db-client.flow.test.ts | 369 ++++++++++++++++++ 2 files changed, 476 insertions(+), 6 deletions(-) create mode 100644 src/client/passwordless/passwordless-db-client.flow.test.ts diff --git a/src/client/passwordless/index.ts b/src/client/passwordless/index.ts index 32872964d..a018748ae 100644 --- a/src/client/passwordless/index.ts +++ b/src/client/passwordless/index.ts @@ -1,4 +1,6 @@ import { + PasswordlessDbChallengeError, + PasswordlessDbGetTokenError, PasswordlessStartError, PasswordlessVerifyError } from "../../errors/index.js"; @@ -144,20 +146,119 @@ class ClientPasswordlessClient implements PasswordlessClient { async challengeWithEmail( options: PasswordlessDbChallengeEmailOptions ): Promise { - void options; - throw new Error("Not implemented"); + const url = normalizeWithBasePath( + process.env.NEXT_PUBLIC_PASSWORDLESS_DB_OTP_CHALLENGE_ROUTE || + "/auth/passwordless/otp/challenge" + ); + + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify(options) + }); + } catch (e) { + throw new PasswordlessDbChallengeError( + "client_error", + e instanceof Error ? e.message : "Network error", + undefined + ); + } + + if (!response.ok) { + const error = await response.json().catch(() => ({ + error: "client_error", + error_description: "Failed to parse error response" + })); + throw new PasswordlessDbChallengeError( + error.error ?? "client_error", + error.error_description ?? "OTP challenge failed", + undefined + ); + } + + const data = await response.json(); + return { authSession: data.authSession }; } async challengeWithPhoneNumber( options: PasswordlessDbChallengePhoneOptions ): Promise { - void options; - throw new Error("Not implemented"); + const url = normalizeWithBasePath( + process.env.NEXT_PUBLIC_PASSWORDLESS_DB_OTP_CHALLENGE_ROUTE || + "/auth/passwordless/otp/challenge" + ); + + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify(options) + }); + } catch (e) { + throw new PasswordlessDbChallengeError( + "client_error", + e instanceof Error ? e.message : "Network error", + undefined + ); + } + + if (!response.ok) { + const error = await response.json().catch(() => ({ + error: "client_error", + error_description: "Failed to parse error response" + })); + throw new PasswordlessDbChallengeError( + error.error ?? "client_error", + error.error_description ?? "OTP challenge failed", + undefined + ); + } + + const data = await response.json(); + return { authSession: data.authSession }; } async loginWithOtp(options: PasswordlessDbGetTokenOptions): Promise { - void options; - throw new Error("Not implemented"); + const url = normalizeWithBasePath( + process.env.NEXT_PUBLIC_PASSWORDLESS_DB_GET_TOKEN_ROUTE || + "/auth/passwordless/otp/token" + ); + + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(options) + }); + } catch (e) { + throw new PasswordlessDbGetTokenError( + "client_error", + e instanceof Error ? e.message : "Network error", + undefined + ); + } + + if (!response.ok) { + const error = await response.json().catch(() => ({ + error: "client_error", + error_description: "Failed to parse error response" + })); + if (error.error === "mfa_required") { + throw error; + } + throw new PasswordlessDbGetTokenError( + error.error ?? "client_error", + error.error_description ?? "OTP token exchange failed", + undefined + ); + } } } diff --git a/src/client/passwordless/passwordless-db-client.flow.test.ts b/src/client/passwordless/passwordless-db-client.flow.test.ts new file mode 100644 index 000000000..e8fa8117e --- /dev/null +++ b/src/client/passwordless/passwordless-db-client.flow.test.ts @@ -0,0 +1,369 @@ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it +} from "vitest"; + +import type { PasswordlessClient } from "../../types/index.js"; + +const DEFAULT = { + appBaseUrl: "http://localhost:3000", + email: "user@example.com", + phoneNumber: "+14155550100", + connection: "my-db-connection", + authSession: "opaque-auth-session-token", + otp: "123456" +}; + +const CHALLENGE_ROUTE = `${DEFAULT.appBaseUrl}/auth/passwordless/otp/challenge`; +const TOKEN_ROUTE = `${DEFAULT.appBaseUrl}/auth/passwordless/otp/token`; + +const server = setupServer(); + +let originalChallengeRoute: string | undefined; +let originalTokenRoute: string | undefined; + +beforeAll(() => { + server.listen({ onUnhandledRequest: "error" }); + + originalChallengeRoute = + process.env.NEXT_PUBLIC_PASSWORDLESS_DB_OTP_CHALLENGE_ROUTE; + originalTokenRoute = process.env.NEXT_PUBLIC_PASSWORDLESS_DB_GET_TOKEN_ROUTE; + + process.env.NEXT_PUBLIC_PASSWORDLESS_DB_OTP_CHALLENGE_ROUTE = CHALLENGE_ROUTE; + process.env.NEXT_PUBLIC_PASSWORDLESS_DB_GET_TOKEN_ROUTE = TOKEN_ROUTE; +}); + +afterEach(() => { + server.resetHandlers(); +}); + +afterAll(() => { + if (originalChallengeRoute === undefined) { + delete process.env.NEXT_PUBLIC_PASSWORDLESS_DB_OTP_CHALLENGE_ROUTE; + } else { + process.env.NEXT_PUBLIC_PASSWORDLESS_DB_OTP_CHALLENGE_ROUTE = + originalChallengeRoute; + } + if (originalTokenRoute === undefined) { + delete process.env.NEXT_PUBLIC_PASSWORDLESS_DB_GET_TOKEN_ROUTE; + } else { + process.env.NEXT_PUBLIC_PASSWORDLESS_DB_GET_TOKEN_ROUTE = + originalTokenRoute; + } + server.close(); +}); + +describe("ClientPasswordlessClient — DB OTP methods", () => { + let client: PasswordlessClient; + + beforeEach(async () => { + const { passwordless } = await import("./index.js"); + client = passwordless; + }); + + // --------------------------------------------------------------------------- + // challengeWithEmail() + // --------------------------------------------------------------------------- + + describe("challengeWithEmail", () => { + it("sends email and connection to the challenge route and returns authSession", async () => { + let capturedBody: Record = {}; + + server.use( + http.post(CHALLENGE_ROUTE, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ authSession: DEFAULT.authSession }); + }) + ); + + const result = await client.challengeWithEmail({ + email: DEFAULT.email, + connection: DEFAULT.connection + }); + + expect(capturedBody.email).toBe(DEFAULT.email); + expect(capturedBody.connection).toBe(DEFAULT.connection); + expect(result.authSession).toBe(DEFAULT.authSession); + }); + + it("sends allowSignup when provided", async () => { + let capturedBody: Record = {}; + + server.use( + http.post(CHALLENGE_ROUTE, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ authSession: DEFAULT.authSession }); + }) + ); + + await client.challengeWithEmail({ + email: DEFAULT.email, + connection: DEFAULT.connection, + allowSignup: true + }); + + expect(capturedBody.allowSignup).toBe(true); + }); + + it("succeeds silently for non-existent user with allowSignup false (200-always contract)", async () => { + server.use( + http.post(CHALLENGE_ROUTE, () => + HttpResponse.json({ authSession: DEFAULT.authSession }) + ) + ); + + const result = await client.challengeWithEmail({ + email: "nonexistent@example.com", + connection: DEFAULT.connection, + allowSignup: false + }); + + expect(result.authSession).toBe(DEFAULT.authSession); + }); + + it("throws PasswordlessDbChallengeError on API error", async () => { + server.use( + http.post(CHALLENGE_ROUTE, () => + HttpResponse.json( + { + error: "invalid_connection", + error_description: "Connection is not a database connection." + }, + { status: 400 } + ) + ) + ); + + const err = await client + .challengeWithEmail({ + email: DEFAULT.email, + connection: "not-a-db-connection" + }) + .catch((e) => e); + + expect(err).toMatchObject({ + name: "PasswordlessDbChallengeError", + error: "invalid_connection", + error_description: "Connection is not a database connection." + }); + }); + + it("throws PasswordlessDbChallengeError on network failure", async () => { + server.use(http.post(CHALLENGE_ROUTE, () => HttpResponse.error())); + + const err = await client + .challengeWithEmail({ + email: DEFAULT.email, + connection: DEFAULT.connection + }) + .catch((e) => e); + + expect(err).toMatchObject({ + name: "PasswordlessDbChallengeError", + error: "client_error" + }); + }); + }); + + // --------------------------------------------------------------------------- + // challengeWithPhoneNumber() + // --------------------------------------------------------------------------- + + describe("challengeWithPhoneNumber", () => { + it("sends phoneNumber and connection to the challenge route and returns authSession", async () => { + let capturedBody: Record = {}; + + server.use( + http.post(CHALLENGE_ROUTE, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ authSession: DEFAULT.authSession }); + }) + ); + + const result = await client.challengeWithPhoneNumber({ + phoneNumber: DEFAULT.phoneNumber, + connection: DEFAULT.connection + }); + + expect(capturedBody.phoneNumber).toBe(DEFAULT.phoneNumber); + expect(capturedBody.connection).toBe(DEFAULT.connection); + expect(result.authSession).toBe(DEFAULT.authSession); + }); + + it("sends deliveryMethod when provided", async () => { + let capturedBody: Record = {}; + + server.use( + http.post(CHALLENGE_ROUTE, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ authSession: DEFAULT.authSession }); + }) + ); + + await client.challengeWithPhoneNumber({ + phoneNumber: DEFAULT.phoneNumber, + connection: DEFAULT.connection, + deliveryMethod: "voice" + }); + + expect(capturedBody.deliveryMethod).toBe("voice"); + }); + + it("throws PasswordlessDbChallengeError on API error", async () => { + server.use( + http.post(CHALLENGE_ROUTE, () => + HttpResponse.json( + { + error: "invalid_request", + error_description: "Phone provider not configured." + }, + { status: 400 } + ) + ) + ); + + const err = await client + .challengeWithPhoneNumber({ + phoneNumber: DEFAULT.phoneNumber, + connection: DEFAULT.connection + }) + .catch((e) => e); + + expect(err).toMatchObject({ + name: "PasswordlessDbChallengeError", + error: "invalid_request" + }); + }); + + it("throws PasswordlessDbChallengeError on network failure", async () => { + server.use(http.post(CHALLENGE_ROUTE, () => HttpResponse.error())); + + const err = await client + .challengeWithPhoneNumber({ + phoneNumber: DEFAULT.phoneNumber, + connection: DEFAULT.connection + }) + .catch((e) => e); + + expect(err).toMatchObject({ + name: "PasswordlessDbChallengeError", + error: "client_error" + }); + }); + }); + + // --------------------------------------------------------------------------- + // loginWithOtp() + // --------------------------------------------------------------------------- + + describe("loginWithOtp", () => { + it("sends authSession and otp to the token route", async () => { + let capturedBody: Record = {}; + + server.use( + http.post(TOKEN_ROUTE, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ success: true }); + }) + ); + + await client.loginWithOtp({ + authSession: DEFAULT.authSession, + otp: DEFAULT.otp + }); + + expect(capturedBody.authSession).toBe(DEFAULT.authSession); + expect(capturedBody.otp).toBe(DEFAULT.otp); + }); + + it("throws PasswordlessDbGetTokenError on invalid OTP", async () => { + server.use( + http.post(TOKEN_ROUTE, () => + HttpResponse.json( + { + error: "invalid_request", + error_description: "Invalid or expired OTP code." + }, + { status: 403 } + ) + ) + ); + + const err = await client + .loginWithOtp({ authSession: DEFAULT.authSession, otp: "wrong" }) + .catch((e) => e); + + expect(err).toMatchObject({ + name: "PasswordlessDbGetTokenError", + error: "invalid_request", + error_description: "Invalid or expired OTP code." + }); + }); + + it("throws PasswordlessDbGetTokenError on expired auth_session", async () => { + server.use( + http.post(TOKEN_ROUTE, () => + HttpResponse.json( + { + error: "invalid_request", + error_description: "Invalid or expired OTP code." + }, + { status: 403 } + ) + ) + ); + + const err = await client + .loginWithOtp({ authSession: "expired-session", otp: DEFAULT.otp }) + .catch((e) => e); + + expect(err).toMatchObject({ + name: "PasswordlessDbGetTokenError", + error: "invalid_request" + }); + }); + + it("re-throws mfa_required raw object instead of wrapping as PasswordlessDbGetTokenError", async () => { + server.use( + http.post(TOKEN_ROUTE, () => + HttpResponse.json( + { + error: "mfa_required", + error_description: "Multi-factor authentication is required.", + mfa_token: "encrypted-mfa-token-value" + }, + { status: 403 } + ) + ) + ); + + const err = await client + .loginWithOtp({ authSession: DEFAULT.authSession, otp: DEFAULT.otp }) + .catch((e) => e); + + expect(err.error).toBe("mfa_required"); + expect(err.mfa_token).toBe("encrypted-mfa-token-value"); + expect(err.name).not.toBe("PasswordlessDbGetTokenError"); + }); + + it("throws PasswordlessDbGetTokenError on network failure", async () => { + server.use(http.post(TOKEN_ROUTE, () => HttpResponse.error())); + + const err = await client + .loginWithOtp({ authSession: DEFAULT.authSession, otp: DEFAULT.otp }) + .catch((e) => e); + + expect(err).toMatchObject({ + name: "PasswordlessDbGetTokenError", + error: "client_error" + }); + }); + }); +}); From 1cbd68bc857c2a80c88e4692021a23dc6ddaad64 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Mon, 29 Jun 2026 18:37:28 +0530 Subject: [PATCH 4/9] fix: extract #sendChallenge helper and fix test setup in passwordless DB client --- src/client/passwordless/index.ts | 44 ++++--------------- .../passwordless-db-client.flow.test.ts | 28 +++++++----- 2 files changed, 27 insertions(+), 45 deletions(-) diff --git a/src/client/passwordless/index.ts b/src/client/passwordless/index.ts index a018748ae..4a2198c70 100644 --- a/src/client/passwordless/index.ts +++ b/src/client/passwordless/index.ts @@ -146,45 +146,19 @@ class ClientPasswordlessClient implements PasswordlessClient { async challengeWithEmail( options: PasswordlessDbChallengeEmailOptions ): Promise { - const url = normalizeWithBasePath( - process.env.NEXT_PUBLIC_PASSWORDLESS_DB_OTP_CHALLENGE_ROUTE || - "/auth/passwordless/otp/challenge" - ); - - let response: Response; - try { - response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "same-origin", - body: JSON.stringify(options) - }); - } catch (e) { - throw new PasswordlessDbChallengeError( - "client_error", - e instanceof Error ? e.message : "Network error", - undefined - ); - } - - if (!response.ok) { - const error = await response.json().catch(() => ({ - error: "client_error", - error_description: "Failed to parse error response" - })); - throw new PasswordlessDbChallengeError( - error.error ?? "client_error", - error.error_description ?? "OTP challenge failed", - undefined - ); - } - - const data = await response.json(); - return { authSession: data.authSession }; + return this.#sendChallenge(options); } async challengeWithPhoneNumber( options: PasswordlessDbChallengePhoneOptions + ): Promise { + return this.#sendChallenge(options); + } + + async #sendChallenge( + options: + | PasswordlessDbChallengeEmailOptions + | PasswordlessDbChallengePhoneOptions ): Promise { const url = normalizeWithBasePath( process.env.NEXT_PUBLIC_PASSWORDLESS_DB_OTP_CHALLENGE_ROUTE || diff --git a/src/client/passwordless/passwordless-db-client.flow.test.ts b/src/client/passwordless/passwordless-db-client.flow.test.ts index e8fa8117e..2bd6e266f 100644 --- a/src/client/passwordless/passwordless-db-client.flow.test.ts +++ b/src/client/passwordless/passwordless-db-client.flow.test.ts @@ -1,14 +1,6 @@ import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it -} from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import type { PasswordlessClient } from "../../types/index.js"; @@ -63,7 +55,7 @@ afterAll(() => { describe("ClientPasswordlessClient — DB OTP methods", () => { let client: PasswordlessClient; - beforeEach(async () => { + beforeAll(async () => { const { passwordless } = await import("./index.js"); client = passwordless; }); @@ -216,6 +208,22 @@ describe("ClientPasswordlessClient — DB OTP methods", () => { expect(capturedBody.deliveryMethod).toBe("voice"); }); + it("succeeds silently for non-existent user with allowSignup false (200-always contract)", async () => { + server.use( + http.post(CHALLENGE_ROUTE, () => + HttpResponse.json({ authSession: DEFAULT.authSession }) + ) + ); + + const result = await client.challengeWithPhoneNumber({ + phoneNumber: "+10000000000", + connection: DEFAULT.connection, + allowSignup: false + }); + + expect(result.authSession).toBe(DEFAULT.authSession); + }); + it("throws PasswordlessDbChallengeError on API error", async () => { server.use( http.post(CHALLENGE_ROUTE, () => From 2edf8d64aec3d73dda5a62598b49b64f40925b52 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Mon, 29 Jun 2026 19:20:20 +0530 Subject: [PATCH 5/9] fix: correct PasswordlessLoginError reference to PasswordlessDbGetTokenError in JSDoc --- src/types/passwordless.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/passwordless.ts b/src/types/passwordless.ts index 0b5fd4ae6..a434812cf 100644 --- a/src/types/passwordless.ts +++ b/src/types/passwordless.ts @@ -198,7 +198,7 @@ export interface PasswordlessClient { /** * Exchange an `authSession` and user-entered OTP for tokens and establish a session. * - * Throws `PasswordlessLoginError` with `error: "invalid_request"` if the + * Throws `PasswordlessDbGetTokenError` with `error: "invalid_request"` if the * `authSession` was non-functional (blocked user, signup disabled for a non-existent * user, wrong OTP, or expired session). * From 377beda0ef7bfe0b5979bbd01c73b840ff53db5d Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Wed, 1 Jul 2026 18:52:21 +0530 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20correct=20403=E2=86=92400=20for=20DB?= =?UTF-8?q?=20OTP=20client=20errors,=20fix=20JSDoc=20typo,=20add=20server?= =?UTF-8?q?=20flow=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/passwordless/index.ts | 2 + .../passwordless-db-client.flow.test.ts | 4 +- src/server/auth-client.ts | 2 +- .../passwordless-db-server.flow.test.ts | 559 ++++++++++++++++++ src/server/passwordless-db.flow.test.ts | 467 +++++++++++++++ src/types/passwordless-db.ts | 2 +- 6 files changed, 1032 insertions(+), 4 deletions(-) create mode 100644 src/server/passwordless-db-server.flow.test.ts create mode 100644 src/server/passwordless-db.flow.test.ts diff --git a/src/client/passwordless/index.ts b/src/client/passwordless/index.ts index 4a2198c70..524847c5d 100644 --- a/src/client/passwordless/index.ts +++ b/src/client/passwordless/index.ts @@ -225,6 +225,8 @@ class ClientPasswordlessClient implements PasswordlessClient { error_description: "Failed to parse error response" })); if (error.error === "mfa_required") { + // Thrown as a raw object so callers can access mfa_token directly + // to initiate the MFA challenge flow. Consistent with verify(). throw error; } throw new PasswordlessDbGetTokenError( diff --git a/src/client/passwordless/passwordless-db-client.flow.test.ts b/src/client/passwordless/passwordless-db-client.flow.test.ts index 2bd6e266f..731e0cea7 100644 --- a/src/client/passwordless/passwordless-db-client.flow.test.ts +++ b/src/client/passwordless/passwordless-db-client.flow.test.ts @@ -299,7 +299,7 @@ describe("ClientPasswordlessClient — DB OTP methods", () => { error: "invalid_request", error_description: "Invalid or expired OTP code." }, - { status: 403 } + { status: 400 } ) ) ); @@ -323,7 +323,7 @@ describe("ClientPasswordlessClient — DB OTP methods", () => { error: "invalid_request", error_description: "Invalid or expired OTP code." }, - { status: 403 } + { status: 400 } ) ) ); diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 4a8ab10ff..c2069ae1b 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -1982,7 +1982,7 @@ export class AuthClient { { status: 500 } ); } - return NextResponse.json(e.toJSON(), { status: 403 }); + return NextResponse.json(e.toJSON(), { status: 400 }); } if (e instanceof SdkError) { return NextResponse.json( diff --git a/src/server/passwordless-db-server.flow.test.ts b/src/server/passwordless-db-server.flow.test.ts new file mode 100644 index 000000000..d00a40db8 --- /dev/null +++ b/src/server/passwordless-db-server.flow.test.ts @@ -0,0 +1,559 @@ +import { NextRequest } from "next/server.js"; +import * as jose from "jose"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { + createAuthorizationServerMetadata, + getDefaultRoutes, + setupMswLifecycle +} from "../test/defaults.js"; +import { generateSecret } from "../test/utils.js"; +import { generateDpopKeyPair } from "../utils/dpopRetry.js"; +import { AuthClient } from "./auth-client.js"; +import { StatelessSessionStore } from "./session/stateless-session-store.js"; +import { TransactionStore } from "./transaction-store.js"; + +const DEFAULT = { + domain: "auth0.local", + clientId: "test-client-id", + clientSecret: "test-client-secret", + appBaseUrl: "http://localhost:3000", + email: "user@example.com", + phoneNumber: "+14155550100", + otp: "123456", + authSession: "opaque-auth-session-token", + connection: "my-db-connection", + accessToken: "test-access-token", + sub: "auth0|test-user-id", + sid: "test-sid-abc" +}; + +const CHALLENGE_URL = `https://${DEFAULT.domain}/otp/challenge`; +const TOKEN_URL = `https://${DEFAULT.domain}/oauth/token`; + +// RSA key pair for signing id_tokens in route handler tests. +// Shared across the file; generated once before all tests run. +let keyPair: jose.GenerateKeyPairResult; + +const server = setupServer( + http.get(`https://${DEFAULT.domain}/.well-known/openid-configuration`, () => { + return HttpResponse.json(createAuthorizationServerMetadata(DEFAULT.domain)); + }), + http.get(`https://${DEFAULT.domain}/.well-known/jwks.json`, async () => { + const jwk = await jose.exportJWK(keyPair.publicKey); + return HttpResponse.json({ + keys: [{ ...jwk, kid: "test-key-1", alg: "RS256", use: "sig" }] + }); + }) +); + +setupMswLifecycle(server); + +beforeAll(async () => { + keyPair = await jose.generateKeyPair("RS256"); +}); + +/** Build a signed id_token using the module-level RSA key pair. */ +async function generateIdToken( + claims: Record = {} +): Promise { + return new jose.SignJWT({ sub: DEFAULT.sub, sid: DEFAULT.sid, ...claims }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer(`https://${DEFAULT.domain}`) + .setAudience(DEFAULT.clientId) + .setIssuedAt() + .setExpirationTime("1h") + .sign(keyPair.privateKey); +} + +describe("AuthClient passwordless DB route handlers", () => { + let secret: string; + let authClient: AuthClient; + + beforeEach(async () => { + secret = await generateSecret(32); + const transactionStore = new TransactionStore({ secret }); + const sessionStore = new StatelessSessionStore({ secret }); + authClient = new AuthClient({ + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + appBaseUrl: DEFAULT.appBaseUrl, + secret, + transactionStore, + sessionStore, + routes: getDefaultRoutes() + }); + }); + + // --------------------------------------------------------------------------- + // POST /auth/passwordless/otp/challenge + // --------------------------------------------------------------------------- + + describe("POST /auth/passwordless/otp/challenge", () => { + it("returns 200 with authSession for valid email challenge", async () => { + server.use( + http.post(CHALLENGE_URL, () => + HttpResponse.json({ auth_session: DEFAULT.authSession }) + ) + ); + + const req = new NextRequest( + new URL("/auth/passwordless/otp/challenge", DEFAULT.appBaseUrl), + { + method: "POST", + body: JSON.stringify({ + email: DEFAULT.email, + connection: DEFAULT.connection + }), + headers: { "Content-Type": "application/json" } + } + ); + + const res = await authClient.handler(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.authSession).toBe(DEFAULT.authSession); + }); + + it("returns 200 with authSession for valid phone challenge", async () => { + server.use( + http.post(CHALLENGE_URL, () => + HttpResponse.json({ auth_session: DEFAULT.authSession }) + ) + ); + + const req = new NextRequest( + new URL("/auth/passwordless/otp/challenge", DEFAULT.appBaseUrl), + { + method: "POST", + body: JSON.stringify({ + phoneNumber: DEFAULT.phoneNumber, + connection: DEFAULT.connection + }), + headers: { "Content-Type": "application/json" } + } + ); + + const res = await authClient.handler(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.authSession).toBe(DEFAULT.authSession); + }); + + it("returns 400 with missing_identifier when neither email nor phoneNumber is provided", async () => { + const req = new NextRequest( + new URL("/auth/passwordless/otp/challenge", DEFAULT.appBaseUrl), + { + method: "POST", + body: JSON.stringify({ connection: DEFAULT.connection }), + headers: { "Content-Type": "application/json" } + } + ); + + const res = await authClient.handler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("missing_identifier"); + }); + + it("returns 400 when connection field is missing", async () => { + const req = new NextRequest( + new URL("/auth/passwordless/otp/challenge", DEFAULT.appBaseUrl), + { + method: "POST", + body: JSON.stringify({ email: DEFAULT.email }), + headers: { "Content-Type": "application/json" } + } + ); + + const res = await authClient.handler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBeTruthy(); + }); + + it("returns 400 with Auth0 error details on invalid_connection", async () => { + server.use( + http.post(CHALLENGE_URL, () => + HttpResponse.json( + { + error: "invalid_connection", + error_description: "Connection is not a database connection." + }, + { status: 400 } + ) + ) + ); + + const req = new NextRequest( + new URL("/auth/passwordless/otp/challenge", DEFAULT.appBaseUrl), + { + method: "POST", + body: JSON.stringify({ + email: DEFAULT.email, + connection: "not-a-db-connection" + }), + headers: { "Content-Type": "application/json" } + } + ); + + const res = await authClient.handler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("invalid_connection"); + expect(body.error_description).toBe( + "Connection is not a database connection." + ); + }); + + it("returns 500 on network failure to Auth0", async () => { + server.use(http.post(CHALLENGE_URL, () => HttpResponse.error())); + + const req = new NextRequest( + new URL("/auth/passwordless/otp/challenge", DEFAULT.appBaseUrl), + { + method: "POST", + body: JSON.stringify({ + email: DEFAULT.email, + connection: DEFAULT.connection + }), + headers: { "Content-Type": "application/json" } + } + ); + + const res = await authClient.handler(req); + expect(res.status).toBe(500); + }); + }); + + // --------------------------------------------------------------------------- + // POST /auth/passwordless/otp/token + // --------------------------------------------------------------------------- + + describe("POST /auth/passwordless/otp/token", () => { + it("returns 200 + Set-Cookie and creates session on valid OTP", async () => { + const idToken = await generateIdToken(); + + server.use( + http.post(TOKEN_URL, () => + HttpResponse.json({ + access_token: DEFAULT.accessToken, + token_type: "Bearer", + expires_in: 86400, + id_token: idToken + }) + ) + ); + + const req = new NextRequest( + new URL("/auth/passwordless/otp/token", DEFAULT.appBaseUrl), + { + method: "POST", + body: JSON.stringify({ + authSession: DEFAULT.authSession, + otp: DEFAULT.otp + }), + headers: { "Content-Type": "application/json" } + } + ); + + const res = await authClient.handler(req); + expect(res.status).toBe(200); + expect((await res.json()).success).toBe(true); + expect(res.headers.get("set-cookie")).toBeTruthy(); + }); + + it("returns 400 for invalid_request (wrong or expired OTP) — not 403 or 500", async () => { + server.use( + http.post(TOKEN_URL, () => + HttpResponse.json( + { + error: "invalid_request", + error_description: "Invalid or expired OTP code." + }, + { status: 400 } + ) + ) + ); + + const req = new NextRequest( + new URL("/auth/passwordless/otp/token", DEFAULT.appBaseUrl), + { + method: "POST", + body: JSON.stringify({ + authSession: DEFAULT.authSession, + otp: "wrong-otp" + }), + headers: { "Content-Type": "application/json" } + } + ); + + const res = await authClient.handler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("invalid_request"); + expect(body.error_description).toBe("Invalid or expired OTP code."); + }); + + it("returns 400 when authSession field is missing", async () => { + const req = new NextRequest( + new URL("/auth/passwordless/otp/token", DEFAULT.appBaseUrl), + { + method: "POST", + body: JSON.stringify({ otp: DEFAULT.otp }), + headers: { "Content-Type": "application/json" } + } + ); + + const res = await authClient.handler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBeTruthy(); + }); + + it("returns 400 when otp field is missing", async () => { + const req = new NextRequest( + new URL("/auth/passwordless/otp/token", DEFAULT.appBaseUrl), + { + method: "POST", + body: JSON.stringify({ authSession: DEFAULT.authSession }), + headers: { "Content-Type": "application/json" } + } + ); + + const res = await authClient.handler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBeTruthy(); + }); + + it("returns 500 for discovery_error", async () => { + server.use( + http.get( + `https://${DEFAULT.domain}/.well-known/openid-configuration`, + () => HttpResponse.error() + ) + ); + + const freshSecret = await generateSecret(32); + const freshClient = new AuthClient({ + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + appBaseUrl: DEFAULT.appBaseUrl, + secret: freshSecret, + transactionStore: new TransactionStore({ secret: freshSecret }), + sessionStore: new StatelessSessionStore({ secret: freshSecret }), + routes: getDefaultRoutes() + }); + + const req = new NextRequest( + new URL("/auth/passwordless/otp/token", DEFAULT.appBaseUrl), + { + method: "POST", + body: JSON.stringify({ + authSession: DEFAULT.authSession, + otp: DEFAULT.otp + }), + headers: { "Content-Type": "application/json" } + } + ); + + const res = await freshClient.handler(req); + expect(res.status).toBe(500); + }); + + it("returns 403 with encrypted mfa_token when Auth0 returns mfa_required", async () => { + server.use( + http.post(TOKEN_URL, () => + HttpResponse.json( + { + error: "mfa_required", + error_description: "Multi-factor authentication required.", + mfa_token: "raw-mfa-token-db" + }, + { status: 403 } + ) + ) + ); + + const req = new NextRequest( + new URL("/auth/passwordless/otp/token", DEFAULT.appBaseUrl), + { + method: "POST", + body: JSON.stringify({ + authSession: DEFAULT.authSession, + otp: DEFAULT.otp + }), + headers: { "Content-Type": "application/json" } + } + ); + + const res = await authClient.handler(req); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe("mfa_required"); + // mfa_token is encrypted — non-empty and not the raw value + expect(typeof body.mfa_token).toBe("string"); + expect(body.mfa_token.length).toBeGreaterThan(0); + expect(body.mfa_token).not.toBe("raw-mfa-token-db"); + }); + + it("does not set a session cookie on mfa_required — no session established yet", async () => { + server.use( + http.post(TOKEN_URL, () => + HttpResponse.json( + { + error: "mfa_required", + error_description: "MFA required.", + mfa_token: "raw-mfa-token-db" + }, + { status: 403 } + ) + ) + ); + + const req = new NextRequest( + new URL("/auth/passwordless/otp/token", DEFAULT.appBaseUrl), + { + method: "POST", + body: JSON.stringify({ + authSession: DEFAULT.authSession, + otp: DEFAULT.otp + }), + headers: { "Content-Type": "application/json" } + } + ); + + const res = await authClient.handler(req); + expect(res.status).toBe(403); + expect(res.headers.get("set-cookie")).toBeNull(); + }); + + it("returns 400 with invalid_issuer when id_token iss does not match domain", async () => { + const idToken = await new jose.SignJWT({ + sub: DEFAULT.sub, + sid: DEFAULT.sid + }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer("https://wrong.tenant.auth0.com") + .setAudience(DEFAULT.clientId) + .setIssuedAt() + .setExpirationTime("1h") + .sign(keyPair.privateKey); + + server.use( + http.post(TOKEN_URL, () => + HttpResponse.json({ + access_token: DEFAULT.accessToken, + token_type: "Bearer", + expires_in: 86400, + id_token: idToken + }) + ) + ); + + const req = new NextRequest( + new URL("/auth/passwordless/otp/token", DEFAULT.appBaseUrl), + { + method: "POST", + body: JSON.stringify({ + authSession: DEFAULT.authSession, + otp: DEFAULT.otp + }), + headers: { "Content-Type": "application/json" } + } + ); + + const res = await authClient.handler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("invalid_issuer"); + }); + }); + + // --------------------------------------------------------------------------- + // DPoP: nonce retry on passwordlessDbGetToken + // --------------------------------------------------------------------------- + + describe("DPoP-enabled passwordlessDbGetToken", () => { + it("retries with server-supplied nonce on use_dpop_nonce and sends DPoP proof on both attempts", async () => { + const dpopKeyPair = await generateDpopKeyPair(); + const dpopSecret = await generateSecret(32); + + const dpopClient = new AuthClient({ + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + appBaseUrl: DEFAULT.appBaseUrl, + secret: dpopSecret, + transactionStore: new TransactionStore({ secret: dpopSecret }), + sessionStore: new StatelessSessionStore({ secret: dpopSecret }), + routes: getDefaultRoutes(), + useDPoP: true, + dpopKeyPair + }); + + const idToken = await generateIdToken(); + const tokenRequests: Array<{ hasDPoP: boolean; dpopNonce?: string }> = []; + let callCount = 0; + + server.use( + http.post(TOKEN_URL, async ({ request }) => { + callCount++; + const dpopHeader = request.headers.get("dpop"); + let dpopNonce: string | undefined; + + if (dpopHeader) { + try { + const [, payloadB64] = dpopHeader.split("."); + const payload = JSON.parse( + Buffer.from(payloadB64, "base64url").toString() + ); + dpopNonce = payload.nonce as string | undefined; + } catch { + // ignore parse errors + } + } + + tokenRequests.push({ hasDPoP: !!dpopHeader, dpopNonce }); + + if (callCount === 1) { + return HttpResponse.json( + { + error: "use_dpop_nonce", + error_description: + "Authorization server requires nonce in DPoP proof" + }, + { + status: 400, + headers: { "dpop-nonce": "server-nonce-db-abc" } + } + ); + } + + return HttpResponse.json({ + access_token: DEFAULT.accessToken, + token_type: "Bearer", + expires_in: 86400, + id_token: idToken + }); + }) + ); + + const result = await dpopClient.passwordlessDbGetToken({ + authSession: DEFAULT.authSession, + otp: DEFAULT.otp + }); + + expect(callCount).toBe(2); + expect(tokenRequests[0].hasDPoP).toBe(true); + expect(tokenRequests[1].hasDPoP).toBe(true); + expect(tokenRequests[1].dpopNonce).toBe("server-nonce-db-abc"); + expect(result.access_token).toBe(DEFAULT.accessToken); + }); + }); +}); diff --git a/src/server/passwordless-db.flow.test.ts b/src/server/passwordless-db.flow.test.ts new file mode 100644 index 000000000..29bc56542 --- /dev/null +++ b/src/server/passwordless-db.flow.test.ts @@ -0,0 +1,467 @@ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { + createAuthorizationServerMetadata, + getDefaultRoutes, + setupMswLifecycle +} from "../test/defaults.js"; +import { generateSecret } from "../test/utils.js"; +import { AuthClient } from "./auth-client.js"; +import { StatelessSessionStore } from "./session/stateless-session-store.js"; +import { TransactionStore } from "./transaction-store.js"; + +const DEFAULT = { + domain: "auth0.local", + clientId: "test-client-id", + clientSecret: "test-client-secret", + appBaseUrl: "http://localhost:3000", + email: "user@example.com", + phoneNumber: "+14155550100", + otp: "123456", + authSession: "opaque-auth-session-token", + connection: "my-db-connection", + accessToken: "test-access-token" +}; + +const CHALLENGE_URL = `https://${DEFAULT.domain}/otp/challenge`; +const TOKEN_URL = `https://${DEFAULT.domain}/oauth/token`; + +const server = setupServer( + http.get(`https://${DEFAULT.domain}/.well-known/openid-configuration`, () => { + return HttpResponse.json(createAuthorizationServerMetadata(DEFAULT.domain)); + }) +); + +setupMswLifecycle(server); + +describe("AuthClient passwordless DB methods", () => { + let secret: string; + let authClient: AuthClient; + + beforeEach(async () => { + secret = await generateSecret(32); + const transactionStore = new TransactionStore({ secret }); + const sessionStore = new StatelessSessionStore({ secret }); + authClient = new AuthClient({ + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + appBaseUrl: DEFAULT.appBaseUrl, + secret, + transactionStore, + sessionStore, + routes: getDefaultRoutes() + }); + }); + + // --------------------------------------------------------------------------- + // passwordlessDbOtpChallenge + // --------------------------------------------------------------------------- + + describe("passwordlessDbOtpChallenge", () => { + it("sends email and connection in request body and returns authSession", async () => { + let capturedBody: Record = {}; + + server.use( + http.post(CHALLENGE_URL, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ auth_session: DEFAULT.authSession }); + }) + ); + + const result = await authClient.passwordlessDbOtpChallenge({ + email: DEFAULT.email, + connection: DEFAULT.connection + }); + + expect(capturedBody.client_id).toBe(DEFAULT.clientId); + expect(capturedBody.client_secret).toBe(DEFAULT.clientSecret); + expect(capturedBody.connection).toBe(DEFAULT.connection); + expect(capturedBody.email).toBe(DEFAULT.email); + expect(capturedBody.phone_number).toBeUndefined(); + expect(capturedBody.allow_signup).toBe(false); + expect(result.authSession).toBe(DEFAULT.authSession); + }); + + it("reads auth_session (snake_case) from Auth0 response and maps to authSession", async () => { + server.use( + http.post(CHALLENGE_URL, () => + HttpResponse.json({ auth_session: "real-auth-session" }) + ) + ); + + const result = await authClient.passwordlessDbOtpChallenge({ + email: DEFAULT.email, + connection: DEFAULT.connection + }); + + expect(result.authSession).toBe("real-auth-session"); + }); + + it("sends phone_number (snake_case) when phoneNumber option is provided", async () => { + let capturedBody: Record = {}; + + server.use( + http.post(CHALLENGE_URL, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ auth_session: DEFAULT.authSession }); + }) + ); + + await authClient.passwordlessDbOtpChallenge({ + phoneNumber: DEFAULT.phoneNumber, + connection: DEFAULT.connection + }); + + expect(capturedBody.phone_number).toBe(DEFAULT.phoneNumber); + expect(capturedBody.email).toBeUndefined(); + }); + + it("sends delivery_method when deliveryMethod option is provided", async () => { + let capturedBody: Record = {}; + + server.use( + http.post(CHALLENGE_URL, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ auth_session: DEFAULT.authSession }); + }) + ); + + await authClient.passwordlessDbOtpChallenge({ + phoneNumber: DEFAULT.phoneNumber, + connection: DEFAULT.connection, + deliveryMethod: "voice" + }); + + expect(capturedBody.delivery_method).toBe("voice"); + }); + + it("does not send delivery_method when not provided", async () => { + let capturedBody: Record = {}; + + server.use( + http.post(CHALLENGE_URL, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ auth_session: DEFAULT.authSession }); + }) + ); + + await authClient.passwordlessDbOtpChallenge({ + phoneNumber: DEFAULT.phoneNumber, + connection: DEFAULT.connection + }); + + expect(capturedBody.delivery_method).toBeUndefined(); + }); + + it("sends allow_signup: true when allowSignup option is provided", async () => { + let capturedBody: Record = {}; + + server.use( + http.post(CHALLENGE_URL, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ auth_session: DEFAULT.authSession }); + }) + ); + + await authClient.passwordlessDbOtpChallenge({ + email: DEFAULT.email, + connection: DEFAULT.connection, + allowSignup: true + }); + + expect(capturedBody.allow_signup).toBe(true); + }); + + it("defaults allow_signup to false when not provided", async () => { + let capturedBody: Record = {}; + + server.use( + http.post(CHALLENGE_URL, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ auth_session: DEFAULT.authSession }); + }) + ); + + await authClient.passwordlessDbOtpChallenge({ + email: DEFAULT.email, + connection: DEFAULT.connection + }); + + expect(capturedBody.allow_signup).toBe(false); + }); + + it("returns authSession (200-always contract) even when user does not exist", async () => { + server.use( + http.post(CHALLENGE_URL, () => + HttpResponse.json({ auth_session: "fake-auth-session-non-existent" }) + ) + ); + + const result = await authClient.passwordlessDbOtpChallenge({ + email: "nonexistent@example.com", + connection: DEFAULT.connection, + allowSignup: false + }); + + expect(result.authSession).toBe("fake-auth-session-non-existent"); + }); + + it("throws PasswordlessDbChallengeError on invalid_connection", async () => { + server.use( + http.post(CHALLENGE_URL, () => + HttpResponse.json( + { + error: "invalid_connection", + error_description: "Connection is not a database connection." + }, + { status: 400 } + ) + ) + ); + + await expect( + authClient.passwordlessDbOtpChallenge({ + email: DEFAULT.email, + connection: "not-a-db-connection" + }) + ).rejects.toMatchObject({ + name: "PasswordlessDbChallengeError", + error: "invalid_connection", + error_description: "Connection is not a database connection." + }); + }); + + it("throws PasswordlessDbChallengeError on invalid_request", async () => { + server.use( + http.post(CHALLENGE_URL, () => + HttpResponse.json( + { + error: "invalid_request", + error_description: "Phone provider not configured." + }, + { status: 400 } + ) + ) + ); + + await expect( + authClient.passwordlessDbOtpChallenge({ + phoneNumber: DEFAULT.phoneNumber, + connection: DEFAULT.connection + }) + ).rejects.toMatchObject({ + name: "PasswordlessDbChallengeError", + error: "invalid_request" + }); + }); + + it("throws PasswordlessDbChallengeError with unexpected_error on network failure", async () => { + server.use(http.post(CHALLENGE_URL, () => HttpResponse.error())); + + await expect( + authClient.passwordlessDbOtpChallenge({ + email: DEFAULT.email, + connection: DEFAULT.connection + }) + ).rejects.toMatchObject({ + name: "PasswordlessDbChallengeError", + error: "unexpected_error" + }); + }); + }); + + // --------------------------------------------------------------------------- + // passwordlessDbGetToken + // --------------------------------------------------------------------------- + + describe("passwordlessDbGetToken", () => { + it("sends auth_session, otp, grant_type and client_secret in request body", async () => { + let capturedParams: URLSearchParams = new URLSearchParams(); + + server.use( + http.post(TOKEN_URL, async ({ request }) => { + capturedParams = new URLSearchParams(await request.text()); + return HttpResponse.json({ + access_token: DEFAULT.accessToken, + token_type: "Bearer", + expires_in: 86400 + }); + }) + ); + + const result = await authClient.passwordlessDbGetToken({ + authSession: DEFAULT.authSession, + otp: DEFAULT.otp + }); + + expect(capturedParams.get("auth_session")).toBe(DEFAULT.authSession); + expect(capturedParams.get("otp")).toBe(DEFAULT.otp); + expect(capturedParams.get("grant_type")).toBe( + "http://auth0.com/oauth/grant-type/passwordless/otp" + ); + expect(capturedParams.get("client_secret")).toBe(DEFAULT.clientSecret); + expect(result.access_token).toBe(DEFAULT.accessToken); + }); + + it("does not send realm or username — DB flow only uses auth_session", async () => { + let capturedParams: URLSearchParams = new URLSearchParams(); + + server.use( + http.post(TOKEN_URL, async ({ request }) => { + capturedParams = new URLSearchParams(await request.text()); + return HttpResponse.json({ + access_token: DEFAULT.accessToken, + token_type: "Bearer", + expires_in: 86400 + }); + }) + ); + + await authClient.passwordlessDbGetToken({ + authSession: DEFAULT.authSession, + otp: DEFAULT.otp + }); + + expect(capturedParams.get("realm")).toBeNull(); + expect(capturedParams.get("username")).toBeNull(); + }); + + it("throws PasswordlessDbGetTokenError on invalid_request (wrong OTP)", async () => { + server.use( + http.post(TOKEN_URL, () => + HttpResponse.json( + { + error: "invalid_request", + error_description: "Invalid or expired OTP code." + }, + { status: 400 } + ) + ) + ); + + await expect( + authClient.passwordlessDbGetToken({ + authSession: DEFAULT.authSession, + otp: "wrong-otp" + }) + ).rejects.toMatchObject({ + name: "PasswordlessDbGetTokenError", + error: "invalid_request", + error_description: "Invalid or expired OTP code." + }); + }); + + it("throws PasswordlessDbGetTokenError on expired auth_session", async () => { + server.use( + http.post(TOKEN_URL, () => + HttpResponse.json( + { + error: "invalid_request", + error_description: "Invalid or expired OTP code." + }, + { status: 400 } + ) + ) + ); + + await expect( + authClient.passwordlessDbGetToken({ + authSession: "expired-session-token", + otp: DEFAULT.otp + }) + ).rejects.toMatchObject({ + name: "PasswordlessDbGetTokenError", + error: "invalid_request" + }); + }); + + it("throws PasswordlessDbGetTokenError with discovery_error on discovery failure", async () => { + server.use( + http.get( + `https://${DEFAULT.domain}/.well-known/openid-configuration`, + () => HttpResponse.error() + ) + ); + + const freshSecret = await generateSecret(32); + const freshClient = new AuthClient({ + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + appBaseUrl: DEFAULT.appBaseUrl, + secret: freshSecret, + transactionStore: new TransactionStore({ secret: freshSecret }), + sessionStore: new StatelessSessionStore({ secret: freshSecret }), + routes: getDefaultRoutes() + }); + + await expect( + freshClient.passwordlessDbGetToken({ + authSession: DEFAULT.authSession, + otp: DEFAULT.otp + }) + ).rejects.toMatchObject({ + name: "PasswordlessDbGetTokenError", + error: "discovery_error" + }); + }); + + it("throws MfaRequiredError when Auth0 returns mfa_required", async () => { + server.use( + http.post(TOKEN_URL, () => + HttpResponse.json( + { + error: "mfa_required", + error_description: "Multi-factor authentication required.", + mfa_token: "raw-mfa-token" + }, + { status: 403 } + ) + ) + ); + + await expect( + authClient.passwordlessDbGetToken({ + authSession: DEFAULT.authSession, + otp: DEFAULT.otp + }) + ).rejects.toMatchObject({ name: "MfaRequiredError" }); + }); + + it("blocked user: challenge succeeds (200-always) then getToken throws invalid_request", async () => { + server.use( + http.post(CHALLENGE_URL, () => + HttpResponse.json({ auth_session: "fake-blocked-session" }) + ), + http.post(TOKEN_URL, () => + HttpResponse.json( + { + error: "invalid_request", + error_description: "Invalid or expired OTP code." + }, + { status: 400 } + ) + ) + ); + + const challenge = await authClient.passwordlessDbOtpChallenge({ + email: DEFAULT.email, + connection: DEFAULT.connection + }); + expect(challenge.authSession).toBe("fake-blocked-session"); + + await expect( + authClient.passwordlessDbGetToken({ + authSession: challenge.authSession, + otp: DEFAULT.otp + }) + ).rejects.toMatchObject({ + name: "PasswordlessDbGetTokenError", + error: "invalid_request" + }); + }); + }); +}); diff --git a/src/types/passwordless-db.ts b/src/types/passwordless-db.ts index 93eb156bd..0de4c1060 100644 --- a/src/types/passwordless-db.ts +++ b/src/types/passwordless-db.ts @@ -65,7 +65,7 @@ export interface PasswordlessDbChallenge { /** * Options to exchange a challenge `auth_session` and user-entered OTP for tokens. * - * Throws `PasswordlessLoginError` with `error: "invalid_request"` if the + * Throws `PasswordlessDbGetTokenError` with `error: "invalid_request"` if the * `auth_session` was non-functional (blocked user, signup disabled for a * non-existent user, wrong OTP, or expired session). */ From ad3fa9ca67cdb9d3578d8e798a302011036e0318 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Wed, 1 Jul 2026 19:34:08 +0530 Subject: [PATCH 7/9] fix: addressing coderabbit review comments --- src/client/passwordless/index.ts | 11 ++++++++--- src/server/auth-client.ts | 12 ++++-------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/client/passwordless/index.ts b/src/client/passwordless/index.ts index 524847c5d..67c23ed1b 100644 --- a/src/client/passwordless/index.ts +++ b/src/client/passwordless/index.ts @@ -157,8 +157,7 @@ class ClientPasswordlessClient implements PasswordlessClient { async #sendChallenge( options: - | PasswordlessDbChallengeEmailOptions - | PasswordlessDbChallengePhoneOptions + PasswordlessDbChallengeEmailOptions | PasswordlessDbChallengePhoneOptions ): Promise { const url = normalizeWithBasePath( process.env.NEXT_PUBLIC_PASSWORDLESS_DB_OTP_CHALLENGE_ROUTE || @@ -193,7 +192,13 @@ class ClientPasswordlessClient implements PasswordlessClient { ); } - const data = await response.json(); + const data = await response.json().catch(() => { + throw new PasswordlessDbChallengeError( + "client_error", + "Failed to parse challenge response", + undefined + ); + }); return { authSession: data.authSession }; } diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 93dac14c7..faa0ce17e 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -5519,8 +5519,7 @@ export class AuthClient { */ async passwordlessDbOtpChallenge( options: - | PasswordlessDbChallengeEmailOptions - | PasswordlessDbChallengePhoneOptions + PasswordlessDbChallengeEmailOptions | PasswordlessDbChallengePhoneOptions ): Promise { const url = new URL("/otp/challenge", this.issuer).toString(); const httpOptions = this.httpOptions(); @@ -5612,10 +5611,6 @@ export class AuthClient { params.append("otp", options.otp); params.append("scope", scope); - if (this.clientSecret) { - params.append("client_secret", this.clientSecret); - } - if (this.authorizationParameters.audience) { params.append( "audience", @@ -5628,12 +5623,13 @@ export class AuthClient { ? oauth.DPoP(this.clientMetadata, this.dpopKeyPair) : undefined; + const tokenMetadata = this.withMtlsEndpoint(authorizationServerMetadata); let tokenEndpointResponse: oauth.TokenEndpointResponse; try { tokenEndpointResponse = await withDPoPNonceRetry( async () => { const httpResponse = await oauth.genericTokenEndpointRequest( - authorizationServerMetadata, + tokenMetadata, this.clientMetadata, await this.getClientAuth(), GRANT_TYPE_PASSWORDLESS_OTP, @@ -5646,7 +5642,7 @@ export class AuthClient { } ); return oauth.processGenericTokenEndpointResponse( - authorizationServerMetadata, + tokenMetadata, this.clientMetadata, httpResponse ); From 21f4c41f0e89ed8376dacbbb1b15563fe8c25063 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Fri, 3 Jul 2026 19:32:37 +0530 Subject: [PATCH 8/9] fix: review comment fixes --- src/server/auth-client.ts | 7 ++- .../passwordless-db-server.flow.test.ts | 53 +++++++++++++++++++ src/server/passwordless-db.flow.test.ts | 21 ++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index faa0ce17e..11dd37b89 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -1991,12 +1991,15 @@ export class AuthClient { bodyRecord.phoneNumber ) { const deliveryMethod = - bodyRecord.deliveryMethod === "voice" ? "voice" : "text"; + bodyRecord.deliveryMethod === "voice" || + bodyRecord.deliveryMethod === "text" + ? bodyRecord.deliveryMethod + : undefined; challenge = await this.passwordlessDbOtpChallenge({ phoneNumber: bodyRecord.phoneNumber, connection, allowSignup, - deliveryMethod + ...(deliveryMethod && { deliveryMethod }) }); } else { return NextResponse.json( diff --git a/src/server/passwordless-db-server.flow.test.ts b/src/server/passwordless-db-server.flow.test.ts index d00a40db8..57c97f364 100644 --- a/src/server/passwordless-db-server.flow.test.ts +++ b/src/server/passwordless-db-server.flow.test.ts @@ -143,6 +143,59 @@ describe("AuthClient passwordless DB route handlers", () => { expect(body.authSession).toBe(DEFAULT.authSession); }); + it("does not forward delivery_method to Auth0 when not provided in request", async () => { + let capturedBody: Record = {}; + + server.use( + http.post(CHALLENGE_URL, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ auth_session: DEFAULT.authSession }); + }) + ); + + const req = new NextRequest( + new URL("/auth/passwordless/otp/challenge", DEFAULT.appBaseUrl), + { + method: "POST", + body: JSON.stringify({ + phoneNumber: DEFAULT.phoneNumber, + connection: DEFAULT.connection + }), + headers: { "Content-Type": "application/json" } + } + ); + + await authClient.handler(req); + expect(capturedBody.delivery_method).toBeUndefined(); + }); + + it("forwards delivery_method to Auth0 when explicitly provided", async () => { + let capturedBody: Record = {}; + + server.use( + http.post(CHALLENGE_URL, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ auth_session: DEFAULT.authSession }); + }) + ); + + const req = new NextRequest( + new URL("/auth/passwordless/otp/challenge", DEFAULT.appBaseUrl), + { + method: "POST", + body: JSON.stringify({ + phoneNumber: DEFAULT.phoneNumber, + connection: DEFAULT.connection, + deliveryMethod: "voice" + }), + headers: { "Content-Type": "application/json" } + } + ); + + await authClient.handler(req); + expect(capturedBody.delivery_method).toBe("voice"); + }); + it("returns 400 with missing_identifier when neither email nor phoneNumber is provided", async () => { const req = new NextRequest( new URL("/auth/passwordless/otp/challenge", DEFAULT.appBaseUrl), diff --git a/src/server/passwordless-db.flow.test.ts b/src/server/passwordless-db.flow.test.ts index 29bc56542..2fbbbbd37 100644 --- a/src/server/passwordless-db.flow.test.ts +++ b/src/server/passwordless-db.flow.test.ts @@ -271,6 +271,27 @@ describe("AuthClient passwordless DB methods", () => { error: "unexpected_error" }); }); + + it("throws PasswordlessDbChallengeError with unexpected_error on 200 with non-JSON body", async () => { + server.use( + http.post(CHALLENGE_URL, () => + new HttpResponse("error", { + status: 200, + headers: { "Content-Type": "text/html" } + }) + ) + ); + + await expect( + authClient.passwordlessDbOtpChallenge({ + email: DEFAULT.email, + connection: DEFAULT.connection + }) + ).rejects.toMatchObject({ + name: "PasswordlessDbChallengeError", + error: "unexpected_error" + }); + }); }); // --------------------------------------------------------------------------- From 40f247f1cd727b312bda6dd63182ae8d30f2b057 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Fri, 3 Jul 2026 19:33:10 +0530 Subject: [PATCH 9/9] fix: lint fix --- src/server/passwordless-db.flow.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/server/passwordless-db.flow.test.ts b/src/server/passwordless-db.flow.test.ts index 2fbbbbd37..5326d0d52 100644 --- a/src/server/passwordless-db.flow.test.ts +++ b/src/server/passwordless-db.flow.test.ts @@ -274,11 +274,13 @@ describe("AuthClient passwordless DB methods", () => { it("throws PasswordlessDbChallengeError with unexpected_error on 200 with non-JSON body", async () => { server.use( - http.post(CHALLENGE_URL, () => - new HttpResponse("error", { - status: 200, - headers: { "Content-Type": "text/html" } - }) + http.post( + CHALLENGE_URL, + () => + new HttpResponse("error", { + status: 200, + headers: { "Content-Type": "text/html" } + }) ) );