fix(auth): persist signup verification sessions and supply delivery URLs

This commit is contained in:
Clintchiz committed 2026-08-26 10:55:48 +05:30
1 parent 84e16ebc41
commit f7fe3bb17b
12 files changed
+237 -8

No files matched your search

+27 -3
View File
@@ -45,6 +45,7 @@ const DEFAULT_TOKEN_TTLS: Record<AuthTokenPurpose, number> = {
"change-email": 30 * 60_000,
"change-phone": 15 * 60_000,
"login-mfa": 5 * 60_000,
"verification-session": 30 * 60_000,
};
export interface AuthEngine {
@@ -52,6 +53,9 @@ export interface AuthEngine {
readonly onSignedIn?: AuthSignedInHandler;
readonly onSignedOut?: AuthSignedOutHandler;
readonly onSuccessfulSignUp?: AuthSuccessfulSignUpHandler;
readonly onSignUp?: AuthSuccessfulSignUpHandler;
createVerificationSession(userId: string): Promise<string>;
validateVerificationSession(token: string): Promise<ReturnType<typeof publicUser> | undefined>;
register(input: RegisterInput): Promise<AuthResult>;
login(input: LoginInput): Promise<AuthResult>;
logout(sessionId: string, reason?: string): Promise<void>;
@@ -264,6 +268,9 @@ function passkeyChallengeTtl(timeout: number): number {
}
export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
if (options.onSignUp && options.onSuccessfulSignUp) {
throw new TypeError("Configure either onSignUp or onSuccessfulSignUp, not both");
}
if (options.secret.length < MIN_SECRET_LENGTH) {
throw new TypeError(`auth secret must be at least ${MIN_SECRET_LENGTH} characters`);
}
@@ -847,7 +854,24 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
store,
onSignedIn: options.onSignedIn,
onSignedOut: options.onSignedOut,
onSuccessfulSignUp: options.onSuccessfulSignUp,
onSuccessfulSignUp: options.onSignUp ?? options.onSuccessfulSignUp,
onSignUp: options.onSignUp ?? options.onSuccessfulSignUp,
async createVerificationSession(userId) {
const user = await store.findUserById(userId);
if (!user || !["pending", "active"].includes(user.status) || user.emailVerified) {
throw new Error("Email verification session is unavailable");
}
return issueToken(user.id, "verification-session");
},
async validateVerificationSession(raw) {
if (!raw || raw.length > 512) return undefined;
const token = await inspectToken(raw, "verification-session");
if (!token) return undefined;
const user = await store.findUserById(token.userId);
if (!user || !["pending", "active"].includes(user.status) || user.emailVerified)
return undefined;
return publicUser(user);
},
async register(input) {
try {
@@ -897,8 +921,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
const identities = await store.listIdentities(user.id);
const email = identities.find((item) => item.type === "email");
const phone = identities.find((item) => item.type === "phone");
if (email) await sendToken(user, "verify-email", email.value);
if (phone) await sendToken(user, "verify-phone", phone.value);
if (email) await sendToken(user, "verify-email", email.value, input.baseUrl);
if (phone) await sendToken(user, "verify-phone", phone.value, input.baseUrl);
await audit({ userId: user.id, type: "account.registered", severity: "info" });
return {
ok: true,
+4 -1
View File
@@ -12,6 +12,7 @@ import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from
import type { AuthSessionVerificationHandler } from "../types.ts";
import { completeAuth, startAuth, type OAuthProvider } from "@wrnexus/oauth";
import { assignDefaultAuthzRoles } from "@wrnexus/authz/defaults";
import { startVerificationSession, getVerificationUser } from "../verification-session.ts";
function text(value: unknown): string {
return typeof value === "string" ? value : value == null ? "" : String(value);
@@ -180,6 +181,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.register({
baseUrl: options.baseUrl ?? ctx.url.origin,
email: text(input.email) || undefined,
phone: text(input.phone) || undefined,
username: text(input.username) || undefined,
@@ -194,6 +196,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
const action = await onSuccessfulSignUp?.(ctx, result.user);
if (action instanceof Response) return action;
if (action?.verificationSession) await startVerificationSession(ctx, engine, result.user.id);
if (action?.autoSignIn) {
const identifier = text(input.email) || text(input.phone) || text(input.username);
const loginResult = await engine.login({
@@ -254,7 +257,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
const validation = await parseBody(schemas.verificationRequest, ctx.req);
if (!validation.ok) return validation.response;
const type = text(validation.value.type) === "phone" ? "phone" : "email";
const current = getAuthUser(ctx);
const current = getAuthUser(ctx) ?? (await getVerificationUser(ctx, engine));
const identifier = text(validation.value.identifier);
const user =
current ?? (identifier ? await engine.findUserByIdentifier(identifier) : undefined);
+6
View File
@@ -1,4 +1,10 @@
export { createAuthEngine, type AuthEngine } from "./engine.ts";
export {
startVerificationSession,
getVerificationUser,
clearVerificationSession,
VERIFICATION_SESSION_COOKIE,
} from "./verification-session.ts";
export type { AuthStore } from "./store.ts";
export { MemoryAuthStore } from "./stores/memory.ts";
export { SqlAuthStore } from "./stores/sql.ts";
+6
View File
@@ -1,4 +1,10 @@
export { createAuthEngine, type AuthEngine } from "../engine.ts";
export {
startVerificationSession,
getVerificationUser,
clearVerificationSession,
VERIFICATION_SESSION_COOKIE,
} from "../verification-session.ts";
export {
createAuthHttpHandlers,
type AuthHttpOptions,
+8 -1
View File
@@ -9,7 +9,8 @@ export type AuthTokenPurpose =
| "invite"
| "change-email"
| "change-phone"
| "login-mfa";
| "login-mfa"
| "verification-session";
export type AuthMfaMethod = "email-otp" | "sms-otp" | "totp" | "recovery-code" | "passkey";
export type AuthAccountStatus = "pending" | "active" | "locked" | "disabled" | "deleted";
export type AuthRiskLevel = "low" | "medium" | "high" | "critical";
@@ -293,6 +294,8 @@ export type AuthSessionVerificationHandler = (
) => Response | Promise<Response>;
export interface AuthSuccessfulSignUpAction {
/** Issue a short-lived verification-only cookie backed by the auth token store. */
verificationSession?: boolean;
/**
* Run the normal login policy with the newly registered credentials and
* establish a session when verification, CAPTCHA, MFA, and account policy
@@ -425,6 +428,8 @@ export interface AuthEngineOptions {
* redirects to /sign-in.
*/
onSuccessfulSignUp?: AuthSuccessfulSignUpHandler;
/** Alias for onSuccessfulSignUp. Configure only one signup hook. */
onSignUp?: AuthSuccessfulSignUpHandler;
breachProvider?: PasswordBreachProvider;
passkeys?: PasskeyProvider;
passkeyChallengeStore?: import("./passkeys/index.ts").PasskeyChallengeStore;
@@ -467,6 +472,8 @@ export interface AuthEngineOptions {
}
export interface RegisterInput {
/** Trusted server-side origin for verification links; never copied from request data. */
baseUrl?: string;
email?: string;
phone?: string;
username?: string;
+36
View File
@@ -0,0 +1,36 @@
import type { Context } from "@wrnexus/core";
import type { AuthEngine } from "./engine.ts";
export const VERIFICATION_SESSION_COOKIE = "wrnexus.auth.verification";
function cookieOptions(ctx: Context) {
return {
httpOnly: true,
secure: ctx.url.protocol === "https:",
sameSite: "Lax" as const,
path: "/",
};
}
/** Call only after successful registration or credential validation, never from a public identifier. */
export async function startVerificationSession(
ctx: Context,
engine: AuthEngine,
userId: string,
): Promise<void> {
await clearVerificationSession(ctx, engine);
const token = await engine.createVerificationSession(userId);
ctx.cookies.set(VERIFICATION_SESSION_COOKIE, token, cookieOptions(ctx));
}
/** Reads the database-backed, purpose-bound session; grants no authenticated app access. */
export async function getVerificationUser(ctx: Context, engine: AuthEngine) {
const token = ctx.cookies.get(VERIFICATION_SESSION_COOKIE);
return token ? engine.validateVerificationSession(token) : undefined;
}
export async function clearVerificationSession(ctx: Context, engine: AuthEngine): Promise<void> {
const token = ctx.cookies.get(VERIFICATION_SESSION_COOKIE);
if (token && token.length <= 512) await engine.consumeToken(token, "verification-session");
ctx.cookies.delete(VERIFICATION_SESSION_COOKIE, cookieOptions(ctx));
}