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
@@ -235,7 +235,7 @@
|
|||||||
},
|
},
|
||||||
"packages/auth": {
|
"packages/auth": {
|
||||||
"name": "@wrnexus/auth",
|
"name": "@wrnexus/auth",
|
||||||
"version": "0.8.18",
|
"version": "0.8.19",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/captcha": "workspace:*",
|
"@wrnexus/captcha": "workspace:*",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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
|
## 0.5.0
|
||||||
|
|
||||||
- Added `AuthEngineOptions.tokenUrl` for custom verification, reset, magic-link, and invitation page URLs without application-owned API handlers.
|
- Added `AuthEngineOptions.tokenUrl` for custom verification, reset, magic-link, and invitation page URLs without application-owned API handlers.
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
# @wrnexus/auth
|
# @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.
|
Framework-native authentication, identity, account-security, and session management for WRNexusJS.
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/auth",
|
"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.",
|
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const DEFAULT_TOKEN_TTLS: Record<AuthTokenPurpose, number> = {
|
|||||||
"change-email": 30 * 60_000,
|
"change-email": 30 * 60_000,
|
||||||
"change-phone": 15 * 60_000,
|
"change-phone": 15 * 60_000,
|
||||||
"login-mfa": 5 * 60_000,
|
"login-mfa": 5 * 60_000,
|
||||||
|
"verification-session": 30 * 60_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AuthEngine {
|
export interface AuthEngine {
|
||||||
@@ -52,6 +53,9 @@ export interface AuthEngine {
|
|||||||
readonly onSignedIn?: AuthSignedInHandler;
|
readonly onSignedIn?: AuthSignedInHandler;
|
||||||
readonly onSignedOut?: AuthSignedOutHandler;
|
readonly onSignedOut?: AuthSignedOutHandler;
|
||||||
readonly onSuccessfulSignUp?: AuthSuccessfulSignUpHandler;
|
readonly onSuccessfulSignUp?: AuthSuccessfulSignUpHandler;
|
||||||
|
readonly onSignUp?: AuthSuccessfulSignUpHandler;
|
||||||
|
createVerificationSession(userId: string): Promise<string>;
|
||||||
|
validateVerificationSession(token: string): Promise<ReturnType<typeof publicUser> | undefined>;
|
||||||
register(input: RegisterInput): Promise<AuthResult>;
|
register(input: RegisterInput): Promise<AuthResult>;
|
||||||
login(input: LoginInput): Promise<AuthResult>;
|
login(input: LoginInput): Promise<AuthResult>;
|
||||||
logout(sessionId: string, reason?: string): Promise<void>;
|
logout(sessionId: string, reason?: string): Promise<void>;
|
||||||
@@ -264,6 +268,9 @@ function passkeyChallengeTtl(timeout: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
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) {
|
if (options.secret.length < MIN_SECRET_LENGTH) {
|
||||||
throw new TypeError(`auth secret must be at least ${MIN_SECRET_LENGTH} characters`);
|
throw new TypeError(`auth secret must be at least ${MIN_SECRET_LENGTH} characters`);
|
||||||
}
|
}
|
||||||
@@ -847,7 +854,24 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
store,
|
store,
|
||||||
onSignedIn: options.onSignedIn,
|
onSignedIn: options.onSignedIn,
|
||||||
onSignedOut: options.onSignedOut,
|
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) {
|
async register(input) {
|
||||||
try {
|
try {
|
||||||
@@ -897,8 +921,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
const identities = await store.listIdentities(user.id);
|
const identities = await store.listIdentities(user.id);
|
||||||
const email = identities.find((item) => item.type === "email");
|
const email = identities.find((item) => item.type === "email");
|
||||||
const phone = identities.find((item) => item.type === "phone");
|
const phone = identities.find((item) => item.type === "phone");
|
||||||
if (email) await sendToken(user, "verify-email", email.value);
|
if (email) await sendToken(user, "verify-email", email.value, input.baseUrl);
|
||||||
if (phone) await sendToken(user, "verify-phone", phone.value);
|
if (phone) await sendToken(user, "verify-phone", phone.value, input.baseUrl);
|
||||||
await audit({ userId: user.id, type: "account.registered", severity: "info" });
|
await audit({ userId: user.id, type: "account.registered", severity: "info" });
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from
|
|||||||
import type { AuthSessionVerificationHandler } from "../types.ts";
|
import type { AuthSessionVerificationHandler } from "../types.ts";
|
||||||
import { completeAuth, startAuth, type OAuthProvider } from "@wrnexus/oauth";
|
import { completeAuth, startAuth, type OAuthProvider } from "@wrnexus/oauth";
|
||||||
import { assignDefaultAuthzRoles } from "@wrnexus/authz/defaults";
|
import { assignDefaultAuthzRoles } from "@wrnexus/authz/defaults";
|
||||||
|
import { startVerificationSession, getVerificationUser } from "../verification-session.ts";
|
||||||
|
|
||||||
function text(value: unknown): string {
|
function text(value: unknown): string {
|
||||||
return typeof value === "string" ? value : value == null ? "" : String(value);
|
return typeof value === "string" ? value : value == null ? "" : String(value);
|
||||||
@@ -180,6 +181,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
|||||||
if (!validation.ok) return validation.response;
|
if (!validation.ok) return validation.response;
|
||||||
const input = validation.value;
|
const input = validation.value;
|
||||||
const result = await engine.register({
|
const result = await engine.register({
|
||||||
|
baseUrl: options.baseUrl ?? ctx.url.origin,
|
||||||
email: text(input.email) || undefined,
|
email: text(input.email) || undefined,
|
||||||
phone: text(input.phone) || undefined,
|
phone: text(input.phone) || undefined,
|
||||||
username: text(input.username) || undefined,
|
username: text(input.username) || undefined,
|
||||||
@@ -194,6 +196,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
|||||||
|
|
||||||
const action = await onSuccessfulSignUp?.(ctx, result.user);
|
const action = await onSuccessfulSignUp?.(ctx, result.user);
|
||||||
if (action instanceof Response) return action;
|
if (action instanceof Response) return action;
|
||||||
|
if (action?.verificationSession) await startVerificationSession(ctx, engine, result.user.id);
|
||||||
if (action?.autoSignIn) {
|
if (action?.autoSignIn) {
|
||||||
const identifier = text(input.email) || text(input.phone) || text(input.username);
|
const identifier = text(input.email) || text(input.phone) || text(input.username);
|
||||||
const loginResult = await engine.login({
|
const loginResult = await engine.login({
|
||||||
@@ -254,7 +257,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
|||||||
const validation = await parseBody(schemas.verificationRequest, ctx.req);
|
const validation = await parseBody(schemas.verificationRequest, ctx.req);
|
||||||
if (!validation.ok) return validation.response;
|
if (!validation.ok) return validation.response;
|
||||||
const type = text(validation.value.type) === "phone" ? "phone" : "email";
|
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 identifier = text(validation.value.identifier);
|
||||||
const user =
|
const user =
|
||||||
current ?? (identifier ? await engine.findUserByIdentifier(identifier) : undefined);
|
current ?? (identifier ? await engine.findUserByIdentifier(identifier) : undefined);
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
export { createAuthEngine, type AuthEngine } from "./engine.ts";
|
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 type { AuthStore } from "./store.ts";
|
||||||
export { MemoryAuthStore } from "./stores/memory.ts";
|
export { MemoryAuthStore } from "./stores/memory.ts";
|
||||||
export { SqlAuthStore } from "./stores/sql.ts";
|
export { SqlAuthStore } from "./stores/sql.ts";
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
export { createAuthEngine, type AuthEngine } from "../engine.ts";
|
export { createAuthEngine, type AuthEngine } from "../engine.ts";
|
||||||
|
export {
|
||||||
|
startVerificationSession,
|
||||||
|
getVerificationUser,
|
||||||
|
clearVerificationSession,
|
||||||
|
VERIFICATION_SESSION_COOKIE,
|
||||||
|
} from "../verification-session.ts";
|
||||||
export {
|
export {
|
||||||
createAuthHttpHandlers,
|
createAuthHttpHandlers,
|
||||||
type AuthHttpOptions,
|
type AuthHttpOptions,
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export type AuthTokenPurpose =
|
|||||||
| "invite"
|
| "invite"
|
||||||
| "change-email"
|
| "change-email"
|
||||||
| "change-phone"
|
| "change-phone"
|
||||||
| "login-mfa";
|
| "login-mfa"
|
||||||
|
| "verification-session";
|
||||||
export type AuthMfaMethod = "email-otp" | "sms-otp" | "totp" | "recovery-code" | "passkey";
|
export type AuthMfaMethod = "email-otp" | "sms-otp" | "totp" | "recovery-code" | "passkey";
|
||||||
export type AuthAccountStatus = "pending" | "active" | "locked" | "disabled" | "deleted";
|
export type AuthAccountStatus = "pending" | "active" | "locked" | "disabled" | "deleted";
|
||||||
export type AuthRiskLevel = "low" | "medium" | "high" | "critical";
|
export type AuthRiskLevel = "low" | "medium" | "high" | "critical";
|
||||||
@@ -293,6 +294,8 @@ export type AuthSessionVerificationHandler = (
|
|||||||
) => Response | Promise<Response>;
|
) => Response | Promise<Response>;
|
||||||
|
|
||||||
export interface AuthSuccessfulSignUpAction {
|
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
|
* Run the normal login policy with the newly registered credentials and
|
||||||
* establish a session when verification, CAPTCHA, MFA, and account policy
|
* establish a session when verification, CAPTCHA, MFA, and account policy
|
||||||
@@ -425,6 +428,8 @@ export interface AuthEngineOptions {
|
|||||||
* redirects to /sign-in.
|
* redirects to /sign-in.
|
||||||
*/
|
*/
|
||||||
onSuccessfulSignUp?: AuthSuccessfulSignUpHandler;
|
onSuccessfulSignUp?: AuthSuccessfulSignUpHandler;
|
||||||
|
/** Alias for onSuccessfulSignUp. Configure only one signup hook. */
|
||||||
|
onSignUp?: AuthSuccessfulSignUpHandler;
|
||||||
breachProvider?: PasswordBreachProvider;
|
breachProvider?: PasswordBreachProvider;
|
||||||
passkeys?: PasskeyProvider;
|
passkeys?: PasskeyProvider;
|
||||||
passkeyChallengeStore?: import("./passkeys/index.ts").PasskeyChallengeStore;
|
passkeyChallengeStore?: import("./passkeys/index.ts").PasskeyChallengeStore;
|
||||||
@@ -467,6 +472,8 @@ export interface AuthEngineOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterInput {
|
export interface RegisterInput {
|
||||||
|
/** Trusted server-side origin for verification links; never copied from request data. */
|
||||||
|
baseUrl?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
username?: 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));
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ function context(request: Request): Context {
|
|||||||
t: (key: string) => key,
|
t: (key: string) => key,
|
||||||
ip: "127.0.0.1",
|
ip: "127.0.0.1",
|
||||||
user: null,
|
user: null,
|
||||||
cookies: {} as Context["cookies"],
|
cookies: { get: () => undefined } as unknown as Context["cookies"],
|
||||||
localStorage: {} as Context["localStorage"],
|
localStorage: {} as Context["localStorage"],
|
||||||
session: {
|
session: {
|
||||||
id: () => "http-test-session",
|
id: () => "http-test-session",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user