fix(auth): persist signup verification sessions and supply delivery URLs
This commit is contained in:
1 parent
84e16ebc41
commit
f7fe3bb17b
12 files changed
+237
-8
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user