From f7fe3bb17b9ce8bec6bc1936015759a5a9eae974 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 26 Aug 2026 10:55:48 +0530 Subject: [PATCH] fix(auth): persist signup verification sessions and supply delivery URLs --- bun.lock | 2 +- packages/auth/CHANGELOG.md | 7 ++ packages/auth/README.md | 37 ++++++++ packages/auth/package.json | 2 +- packages/auth/src/engine.ts | 30 ++++++- packages/auth/src/http/index.ts | 5 +- packages/auth/src/index.ts | 6 ++ packages/auth/src/server/index.ts | 6 ++ packages/auth/src/types.ts | 9 +- packages/auth/src/verification-session.ts | 36 ++++++++ packages/auth/test/http.test.ts | 2 +- packages/auth/test/signup-session.test.ts | 103 ++++++++++++++++++++++ 12 files changed, 237 insertions(+), 8 deletions(-) create mode 100644 packages/auth/src/verification-session.ts create mode 100644 packages/auth/test/signup-session.test.ts diff --git a/bun.lock b/bun.lock index 21b163a4..1f27b58f 100644 --- a/bun.lock +++ b/bun.lock @@ -235,7 +235,7 @@ }, "packages/auth": { "name": "@wrnexus/auth", - "version": "0.8.18", + "version": "0.8.19", "dependencies": { "@wrnexus/authz": "workspace:*", "@wrnexus/captcha": "workspace:*", diff --git a/packages/auth/CHANGELOG.md b/packages/auth/CHANGELOG.md index 893bab09..b301a633 100644 --- a/packages/auth/CHANGELOG.md +++ b/packages/auth/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.8.19 + +- Pass the trusted HTTP origin into registration delivery, so signup verification links are populated without custom URL callbacks. +- Add `onSignUp` as an alias for `onSuccessfulSignUp`, rejecting ambiguous duplicate hooks. +- Add a `verificationSession` signup action and database-backed, hashed, purpose-bound verification cookies. Sessions survive engine reloads, do not authenticate users, and expire after 30 minutes by default. +- Verification resend resolves the pending cookie's user before considering an anonymous identifier. + ## 0.5.0 - Added `AuthEngineOptions.tokenUrl` for custom verification, reset, magic-link, and invitation page URLs without application-owned API handlers. diff --git a/packages/auth/README.md b/packages/auth/README.md index 4157d954..52e5b957 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -1,5 +1,42 @@ # @wrnexus/auth +### Signup business logic and verification-only sessions + +```ts +const auth = createAuthEngine({ + store: () => new SqlAuthStore(getDb()), + secret: process.env.AUTH_SECRET!, + requireVerifiedEmail: true, + onSignUp(ctx, user) { + // Application business logic can run here after successful registration. + return { verificationSession: true, redirectTo: "/verify-email" }; + }, + delivery: { + async send(message) { + // message.channel, template, destination, user, expiresAt + // Verification uses message.token and message.url; OTP flows use message.code. + // Render your branded content here before passing it to your provider. + }, + }, +}); +``` + +`onSuccessfulSignUp` remains supported; configure only one signup hook. Hooks run +from the HTTP registration handler and may return an action or a custom Response. +The request origin (or configured `auth.baseUrl`) supplies signup link URLs; a +client-supplied `baseUrl` is never trusted. Direct `engine.register` callers may +provide a trusted `baseUrl` explicitly. + +The verification cookie is HttpOnly, SameSite=Lax, Secure on HTTPS, and host-only. +Its opaque token is stored hashed in the auth token store, separate from login +sessions; it cannot bypass email verification, MFA, or account policy. Use +`getVerificationUser(ctx, auth)` to guard your verification page and +`clearVerificationSession(ctx, auth)` after verification. Use +`startVerificationSession(ctx, auth, user.id)` only after credential validation +when an unverified user signs in again. SQL-backed sessions survive server reloads; +memory stores remain development/test-only. No email or SMS provider is configured +automatically. + Framework-native authentication, identity, account-security, and session management for WRNexusJS. ## Capabilities diff --git a/packages/auth/package.json b/packages/auth/package.json index cad9d490..2f7877db 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/auth", - "version": "0.8.18", + "version": "0.8.19", "description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.", "type": "module", "sideEffects": false, diff --git a/packages/auth/src/engine.ts b/packages/auth/src/engine.ts index e1b74b82..e5d1acea 100644 --- a/packages/auth/src/engine.ts +++ b/packages/auth/src/engine.ts @@ -45,6 +45,7 @@ const DEFAULT_TOKEN_TTLS: Record = { "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; + validateVerificationSession(token: string): Promise | undefined>; register(input: RegisterInput): Promise; login(input: LoginInput): Promise; logout(sessionId: string, reason?: string): Promise; @@ -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, diff --git a/packages/auth/src/http/index.ts b/packages/auth/src/http/index.ts index aa1520ea..ee4f4483 100644 --- a/packages/auth/src/http/index.ts +++ b/packages/auth/src/http/index.ts @@ -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); diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index b7fb1854..4db3961a 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -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"; diff --git a/packages/auth/src/server/index.ts b/packages/auth/src/server/index.ts index 9945dbcd..6db3a9d0 100644 --- a/packages/auth/src/server/index.ts +++ b/packages/auth/src/server/index.ts @@ -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, diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index 38bd75fd..be33fb69 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -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; 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; diff --git a/packages/auth/src/verification-session.ts b/packages/auth/src/verification-session.ts new file mode 100644 index 00000000..97e8f3cd --- /dev/null +++ b/packages/auth/src/verification-session.ts @@ -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 { + 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 { + 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)); +} diff --git a/packages/auth/test/http.test.ts b/packages/auth/test/http.test.ts index 5baf85eb..e9912bbf 100644 --- a/packages/auth/test/http.test.ts +++ b/packages/auth/test/http.test.ts @@ -15,7 +15,7 @@ function context(request: Request): Context { t: (key: string) => key, ip: "127.0.0.1", user: null, - cookies: {} as Context["cookies"], + cookies: { get: () => undefined } as unknown as Context["cookies"], localStorage: {} as Context["localStorage"], session: { id: () => "http-test-session", diff --git a/packages/auth/test/signup-session.test.ts b/packages/auth/test/signup-session.test.ts new file mode 100644 index 00000000..d526600e --- /dev/null +++ b/packages/auth/test/signup-session.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from "bun:test"; +import { createContext } from "@wrnexus/core"; +import { createAuthEngine } from "../src/engine.ts"; +import { createAuthHttpHandlers } from "../src/http/index.ts"; +import { MemoryAuthStore } from "../src/stores/memory.ts"; +import { getVerificationUser, VERIFICATION_SESSION_COOKIE } from "../src/verification-session.ts"; +import type { AuthDeliveryMessage } from "../src/types.ts"; + +test("onSignUp issues a persistent verification-only cookie and registration receives a trusted link origin", async () => { + const store = new MemoryAuthStore(); + const messages: AuthDeliveryMessage[] = []; + let hookCalls = 0; + const options = { + store, + secret: "signup-session-test-secret-longer-than-32-characters", + requireVerifiedEmail: true, + onSignUp() { + hookCalls++; + return { verificationSession: true, redirectTo: "/verify-email" }; + }, + delivery: { + async send(message: AuthDeliveryMessage) { + messages.push(message); + }, + }, + }; + const engine = createAuthEngine(options); + const url = new URL("https://sso.example.test/api/auth/register"); + const ctx = createContext( + new Request(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + displayName: "Signup Test", + email: "signup@example.test", + password: "StrongPassword123!", + consent: true, + baseUrl: "https://attacker.example", + }), + }), + url, + ); + const response = await createAuthHttpHandlers({ engine }).register(ctx); + expect(response.status).toBe(303); + expect(response.headers.get("location")).toBe("https://sso.example.test/verify-email"); + expect(hookCalls).toBe(1); + expect(messages[0]!.url).toBe( + `https://sso.example.test/verify-email?token=${messages[0]!.token}`, + ); + expect(messages[0]!.url).not.toContain("attacker"); + const setCookie = ctx.cookies + .headers() + .filter((value) => value.startsWith(`${VERIFICATION_SESSION_COOKIE}=`)) + .at(-1)!; + expect(setCookie).toContain("HttpOnly"); + expect(setCookie).toContain("Secure"); + expect(setCookie).toContain("SameSite=Lax"); + const cookie = setCookie.split(";")[0]!; + const restartedEngine = createAuthEngine(options); + const nextUrl = new URL("https://sso.example.test/api/auth/verification/request"); + const next = createContext( + new Request(nextUrl, { + method: "POST", + headers: { cookie, "content-type": "application/json" }, + body: JSON.stringify({ type: "email", identifier: "attacker@example.test" }), + }), + nextUrl, + ); + const pending = await getVerificationUser(next, restartedEngine); + expect(pending?.id).toBe(messages[0]!.user.id); + expect(next.user).toBeUndefined(); + expect( + (await createAuthHttpHandlers({ engine: restartedEngine }).requestVerification(next)).status, + ).toBe(200); + expect(messages[1]!.destination).toBe("signup@example.test"); + const token = decodeURIComponent(cookie.slice(cookie.indexOf("=") + 1)); + expect(await restartedEngine.validateSession(token)).toBeUndefined(); + expect((await restartedEngine.verifyEmail(token)).ok).toBe(false); + expect((await restartedEngine.verifyEmail(messages[1]!.token!)).ok).toBe(true); + expect(await getVerificationUser(next, restartedEngine)).toBeUndefined(); +}); + +test("verification sessions reject expiry, tampering, wrong purpose, and revoked tokens", async () => { + let time = 100_000; + const engine = createAuthEngine({ + store: new MemoryAuthStore(), + secret: "signup-session-test-secret-longer-than-32-characters", + clock: { now: () => time }, + }); + const user = ( + await engine.register({ email: "expiry@example.test", password: "StrongPassword123!" }) + ).user!; + const token = await engine.createVerificationSession(user.id); + expect(await engine.validateVerificationSession(`${token}x`)).toBeUndefined(); + expect( + await engine.validateVerificationSession(await engine.issueToken(user.id, "verify-email")), + ).toBeUndefined(); + time += 30 * 60_000 + 1; + expect(await engine.validateVerificationSession(token)).toBeUndefined(); + const renewed = await engine.createVerificationSession(user.id); + await engine.consumeToken(renewed, "verification-session"); + expect(await engine.validateVerificationSession(renewed)).toBeUndefined(); +});