release: WRNexusJS 0.5.0

This commit is contained in:
2026-07-29 12:51:10 +05:30
parent 76c768099d
commit 6afe32f63f
456 changed files with 40879 additions and 8850 deletions
+16
View File
@@ -0,0 +1,16 @@
export interface AuthRuntimeApi {
mount(root?: ParentNode): void;
unmount(root?: ParentNode): void;
registerPasskey(element: HTMLElement): Promise<void>;
authenticatePasskey(element: HTMLElement): Promise<void>;
}
declare global {
interface Window {
WRNexusAuth?: AuthRuntimeApi;
}
}
export function authRuntime(): AuthRuntimeApi | undefined {
return typeof window === "undefined" ? undefined : window.WRNexusAuth;
}
+96
View File
@@ -0,0 +1,96 @@
import { hmacSign, sha256 } from "@wrnexus/encryption";
const encoder = new TextEncoder();
const MAX_RANDOM_ROUNDS = 128;
export function bytesToBase64Url(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
export function base64UrlToBytes(value: string): Uint8Array {
if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
throw new TypeError("Invalid base64url value");
}
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
let binary: string;
try {
binary = atob(padded);
} catch {
throw new TypeError("Invalid base64url value");
}
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
if (bytesToBase64Url(bytes) !== value) throw new TypeError("Invalid base64url value");
return bytes;
}
export function randomToken(random: (length: number) => Uint8Array, bytes = 32): string {
if (!Number.isInteger(bytes) || bytes <= 0) {
throw new RangeError("random token byte length must be a positive integer");
}
const value = random(bytes);
if (!(value instanceof Uint8Array) || value.length !== bytes) {
throw new TypeError(`random byte provider must return exactly ${bytes} bytes`);
}
return bytesToBase64Url(value);
}
export function randomDigits(random: (length: number) => Uint8Array, length = 6): string {
return randomFromAlphabet(random, "0123456789", length);
}
export function randomReadableCode(random: (length: number) => Uint8Array, length = 10): string {
return randomFromAlphabet(random, "ABCDEFGHJKLMNPQRSTUVWXYZ23456789", length);
}
function randomFromAlphabet(
random: (length: number) => Uint8Array,
alphabet: string,
length: number,
): string {
if (!Number.isInteger(length) || length < 0) throw new RangeError("length must be non-negative");
if (!alphabet.length || alphabet.length > 256 || new Set(alphabet).size !== alphabet.length) {
throw new TypeError("alphabet must contain 1 to 256 unique characters");
}
if (length === 0) return "";
const limit = Math.floor(256 / alphabet.length) * alphabet.length;
let output = "";
for (let round = 0; output.length < length && round < MAX_RANDOM_ROUNDS; round += 1) {
const requested = Math.max(16, (length - output.length) * 2);
const bytes = random(requested);
if (!(bytes instanceof Uint8Array) || bytes.length !== requested) {
throw new TypeError(`random byte provider must return exactly ${requested} bytes`);
}
for (const byte of bytes) {
if (byte >= limit) continue;
output += alphabet[byte % alphabet.length];
if (output.length === length) break;
}
}
if (output.length !== length) {
throw new Error("WRN-AUTH-RANDOM-SOURCE-REJECTED");
}
return output;
}
export async function hashSecret(value: string, secret: string): Promise<string> {
return hmacSign(value, secret);
}
export async function fingerprint(value: string): Promise<string> {
return sha256(value);
}
export async function constantTimeEqual(left: string, right: string): Promise<boolean> {
const leftBytes = encoder.encode(left);
const rightBytes = encoder.encode(right);
const length = Math.max(leftBytes.length, rightBytes.length);
let diff = leftBytes.length ^ rightBytes.length;
for (let index = 0; index < length; index += 1) {
diff |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0);
}
return diff === 0;
}
File diff suppressed because it is too large Load Diff
+525
View File
@@ -0,0 +1,525 @@
import type { Context } from "@wrnexus/core";
import { invalid, parseBody, type ObjectSchema } from "@wrnexus/validation";
import type { AuthEngine } from "../engine.ts";
import { safeAuthReturnTo } from "../normalize.ts";
import {
clearAuthSession,
establishAuthSession,
getAuthSession,
getAuthUser,
} from "../middleware.ts";
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "../validation.ts";
import type { AuthSignedInHandler, AuthSignedOutHandler } from "../types.ts";
function text(value: unknown): string {
return typeof value === "string" ? value : value == null ? "" : String(value);
}
function boolean(value: unknown): boolean {
return value === true || value === "true" || value === "on" || value === "1";
}
function json(data: unknown, status = 200): Response {
return Response.json(data, {
status,
headers: { "cache-control": "no-store" },
});
}
function parseValues<T = Record<string, unknown>>(
schema: ObjectSchema,
input: Record<string, unknown>,
): { ok: true; value: T } | { ok: false; response: Response } {
const result = schema.parse(input);
if (!result.ok) return { ok: false, response: invalid(result.errors) };
return { ok: true, value: result.value as T };
}
export interface AuthPasskeyHttpOptions {
/** Relying-party ID accepted by the server. Defaults to the request hostname. */
rpId?: string;
/** Display name included in registration options. */
rpName?: string;
/** Exact WebAuthn origin accepted by the server. Defaults to the request origin. */
origin?: string;
}
export interface AuthHttpOptions {
engine: AuthEngine;
baseUrl?: string;
schemas?: AuthSchemaOverrides | AuthSchemaSet;
passkey?: AuthPasskeyHttpOptions;
/** @deprecated Prefer createAuthEngine({ onSignedIn }). */
onSignedIn?: AuthSignedInHandler;
/** @deprecated Prefer createAuthEngine({ onSignedOut }). */
onSignedOut?: AuthSignedOutHandler;
}
export function createAuthHttpHandlers(options: AuthHttpOptions) {
const engine = options.engine;
const schemas = resolveAuthSchemas(options.schemas);
const onSignedIn = options.onSignedIn ?? engine.onSignedIn;
const onSignedOut = options.onSignedOut ?? engine.onSignedOut;
const onSuccessfulSignUp = engine.onSuccessfulSignUp;
function signupRedirect(ctx: Context, value: string | undefined, fallback: string): Response {
const path = safeAuthReturnTo(value, ctx.url.origin) ?? fallback;
return Response.redirect(new URL(path, ctx.url), 303);
}
function passkeyConfig(ctx: Context): { rpId: string; rpName: string; origin: string } {
const origin = options.passkey?.origin ?? ctx.url.origin;
let originHostname = ctx.url.hostname;
try {
originHostname = new URL(origin).hostname;
} catch {
// Configuration validation belongs to application startup; retain a safe request fallback.
}
return {
rpId: options.passkey?.rpId ?? originHostname,
rpName: options.passkey?.rpName ?? "WRNexusJS",
origin,
};
}
return {
async register(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.register, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.register({
email: text(input.email) || undefined,
phone: text(input.phone) || undefined,
username: text(input.username) || undefined,
password: text(input.password),
displayName: text(input.displayName) || undefined,
locale: text(input.locale) || undefined,
timezone: text(input.timezone) || undefined,
});
if (!result.ok || !result.user) return json(result, 400);
const action = await onSuccessfulSignUp?.(ctx, result.user);
if (action instanceof Response) return action;
if (action?.autoSignIn) {
const identifier = text(input.email) || text(input.phone) || text(input.username);
const loginResult = await engine.login({
identifier,
password: text(input.password),
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
captchaVerified:
Boolean((ctx.locals.captcha as { success?: boolean } | undefined)?.success) ||
ctx.locals.captchaVerified === true,
});
if (!loginResult.ok || !loginResult.session || !loginResult.user) {
return json(loginResult, 401);
}
establishAuthSession(ctx, loginResult.session, loginResult.user);
return signupRedirect(ctx, action.redirectTo, "/account");
}
return signupRedirect(ctx, action?.redirectTo, "/sign-in");
},
async login(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.login, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.login({
identifier: text(input.identifier),
password: text(input.password),
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
fingerprint: text(input.deviceFingerprint) || undefined,
deviceName: text(input.deviceName) || undefined,
rememberDevice: boolean(input.rememberDevice),
captchaVerified:
Boolean((ctx.locals.captcha as { success?: boolean } | undefined)?.success) ||
ctx.locals.captchaVerified === true,
signals: {
automationSuspected: Boolean(
ctx.locals.captchaRisk && (ctx.locals.captchaRisk as { challenge?: boolean }).challenge,
),
},
});
if (!result.ok || !result.session || !result.user) return json(result, 401);
establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) {
return onSignedIn(ctx, safeAuthReturnTo(text(input.returnTo) || undefined, ctx.url.origin));
}
return json(result);
},
async logout(ctx: Context): Promise<Response> {
const session = getAuthSession(ctx);
if (session) await engine.logout(session.id);
clearAuthSession(ctx);
return onSignedOut ? onSignedOut(ctx) : json({ ok: true });
},
async requestVerification(ctx: Context): Promise<Response> {
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 identifier = text(validation.value.identifier);
const user =
current ?? (identifier ? await engine.findUserByIdentifier(identifier) : undefined);
if (user) {
await engine.requestVerification(user.id, type, options.baseUrl ?? ctx.url.origin);
}
// Always return the same response to avoid revealing account existence.
return json({ ok: true });
},
async verifyEmail(ctx: Context): Promise<Response> {
const validation =
ctx.req.method === "GET"
? parseValues(schemas.verificationToken, {
token: ctx.url.searchParams.get("token"),
})
: await parseBody(schemas.verificationToken, ctx.req);
if (!validation.ok) return validation.response;
const result = await engine.verifyEmail(text(validation.value.token));
return json(result, result.ok ? 200 : 400);
},
async verifyPhone(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.verificationToken, ctx.req);
if (!validation.ok) return validation.response;
const result = await engine.verifyPhone(text(validation.value.token));
return json(result, result.ok ? 200 : 400);
},
async requestPasswordReset(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.passwordResetRequest, ctx.req);
if (!validation.ok) return validation.response;
await engine.requestPasswordReset(
text(validation.value.identifier),
options.baseUrl ?? ctx.url.origin,
);
return json({ ok: true });
},
async resetPassword(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.passwordReset, ctx.req);
if (!validation.ok) return validation.response;
const result = await engine.resetPassword(
text(validation.value.token),
text(validation.value.password),
);
return json(result, result.ok ? 200 : 400);
},
async acceptInvitation(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.invitationAccept, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.acceptInvitation(text(input.token), {
password: text(input.password) || undefined,
displayName: text(input.displayName) || undefined,
});
return json(result, result.ok ? 200 : 400);
},
async requestMagicLink(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.magicLinkRequest, ctx.req);
if (!validation.ok) return validation.response;
await engine.requestMagicLink(
text(validation.value.identifier),
options.baseUrl ?? ctx.url.origin,
);
return json({ ok: true });
},
async consumeMagicLink(ctx: Context): Promise<Response> {
const validation =
ctx.req.method === "GET"
? parseValues(schemas.magicLinkConsume, {
token: ctx.url.searchParams.get("token"),
returnTo: ctx.url.searchParams.get("returnTo") ?? undefined,
})
: await parseBody(schemas.magicLinkConsume, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.consumeMagicLink(text(input.token), {
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
});
if (result.code === "mfa-required") return json(result, 401);
if (!result.ok || !result.session || !result.user) return json(result, 400);
establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) {
return onSignedIn(ctx, safeAuthReturnTo(text(input.returnTo) || undefined, ctx.url.origin));
}
return json(result);
},
async requestOtpLogin(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.otpLoginRequest, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const method = text(input.method) === "sms-otp" ? "sms-otp" : "email-otp";
const challenge = await engine.requestOtpLogin(text(input.identifier), method);
return json({ ok: true, challenge: challenge ?? null });
},
async completeOtpLogin(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.otpLoginComplete, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.completeOtpLogin(text(input.challengeId), text(input.code), {
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
});
if (result.code === "mfa-required") return json(result, 401);
if (!result.ok || !result.session || !result.user) return json(result, 400);
establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) {
return onSignedIn(ctx, safeAuthReturnTo(text(input.returnTo) || undefined, ctx.url.origin));
}
return json(result);
},
async issueOtp(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.otpIssue, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const method = text(input.method) === "sms-otp" ? "sms-otp" : "email-otp";
try {
return json({
ok: true,
...(await engine.issueOtp(user.id, method, text(input.destination) || undefined)),
});
} catch (error) {
return json(
{
ok: false,
error: error instanceof Error ? error.message : "Unable to issue OTP",
},
400,
);
}
},
async verifyOtp(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.otpVerify, ctx.req);
if (!validation.ok) return validation.response;
const result = await engine.verifyOtp(
text(validation.value.challengeId),
text(validation.value.code),
"verification",
);
return json(result, result.ok ? 200 : 400);
},
async beginTotp(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.authenticatorSetup, ctx.req);
if (!validation.ok) return validation.response;
const setup = await engine.beginTotp(
user.id,
text(validation.value.label) || "Authenticator",
);
return json({ ok: true, ...setup });
},
async confirmTotp(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.authenticatorConfirm, ctx.req);
if (!validation.ok) return validation.response;
const ok = await engine.confirmTotp(
user.id,
text(validation.value.credentialId),
text(validation.value.code),
);
return ok
? json({ ok: true })
: json({ ok: false, error: "Authenticator code is invalid" }, 400);
},
async disableTotp(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.authenticatorDisable, ctx.req);
if (!validation.ok) return validation.response;
const ok = await engine.disableTotp(user.id, text(validation.value.credentialId));
return ok
? json({ ok: true })
: json({ ok: false, error: "Authenticator credential was not found" }, 404);
},
async recoveryCodes(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.recoveryCodes, ctx.req);
if (!validation.ok) return validation.response;
const count = typeof validation.value.count === "number" ? validation.value.count : undefined;
const codes = await engine.generateRecoveryCodes(user.id, count);
return json({ ok: true, codes });
},
async changePassword(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.changePassword, ctx.req);
if (!validation.ok) return validation.response;
const result = await engine.changePassword(
user.id,
text(validation.value.currentPassword),
text(validation.value.nextPassword),
);
if (result.ok) clearAuthSession(ctx);
return json(result, result.ok ? 200 : 400);
},
async beginMfaOtp(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.mfaOtpRequest, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const method = text(input.method) === "sms-otp" ? "sms-otp" : "email-otp";
const challenge = await engine.beginMfaOtp(text(input.mfaToken), method);
return challenge
? json({ ok: true, ...challenge })
: json({ ok: false, error: "MFA transaction is invalid or expired" }, 400);
},
async completeMfa(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.mfaComplete, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const methodValue = text(input.method);
const method = ["totp", "recovery-code", "email-otp", "sms-otp"].includes(methodValue)
? (methodValue as "totp" | "recovery-code" | "email-otp" | "sms-otp")
: "totp";
const result = await engine.completeMfa({
mfaToken: text(input.mfaToken),
method,
code: text(input.code),
challengeId: text(input.challengeId) || undefined,
session: {
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
},
});
if (result.code === "mfa-required") return json(result, 401);
if (!result.ok || !result.session || !result.user) return json(result, 400);
establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) {
return onSignedIn(ctx, safeAuthReturnTo(text(input.returnTo) || undefined, ctx.url.origin));
}
return json(result);
},
async startImpersonation(ctx: Context): Promise<Response> {
const actor = getAuthUser(ctx);
const current = getAuthSession(ctx);
if (!actor) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.impersonationStart, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.startImpersonation(actor.id, text(input.targetUserId), {
reason: text(input.reason) || undefined,
sessionId: current?.id,
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
});
if (!result.ok || !result.session || !result.user) return json(result, 403);
establishAuthSession(ctx, result.session, result.user);
return json(result);
},
async stopImpersonation(ctx: Context): Promise<Response> {
const current = getAuthSession(ctx);
if (!current) return json({ ok: false, error: "Unauthorized" }, 401);
const result = await engine.stopImpersonation(current.id);
if (result.code === "mfa-required") return json(result, 401);
if (!result.ok || !result.session || !result.user) return json(result, 400);
establishAuthSession(ctx, result.session, result.user);
return json(result);
},
async sessions(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
return json({
ok: true,
sessions: await engine.listSessions(user.id),
currentSessionId: getAuthSession(ctx)?.id,
});
},
async revokeSession(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.sessionRevoke, ctx.req);
if (!validation.ok) return validation.response;
return json({
ok: await engine.revokeSession(user.id, text(validation.value.sessionId)),
});
},
async passkeyRegistrationOptions(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.passkeyRegistrationOptions, ctx.req);
if (!validation.ok) return validation.response;
const config = passkeyConfig(ctx);
return json({
ok: true,
...(await engine.beginPasskeyRegistration(user.id, config)),
});
},
async passkeyRegistrationVerify(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.passkeyRegistrationVerify, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const ok = await engine.finishPasskeyRegistration(user.id, {
key: text(input.key),
response: input.response,
name: text(input.name) || undefined,
});
return json({ ok }, ok ? 200 : 400);
},
async passkeyAuthenticationOptions(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.passkeyAuthenticationOptions, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const config = passkeyConfig(ctx);
return json({
ok: true,
...(await engine.beginPasskeyAuthentication({
identifier: text(input.identifier) || undefined,
rpId: config.rpId,
origin: config.origin,
})),
});
},
async passkeyAuthenticationVerify(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.passkeyAuthenticationVerify, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.finishPasskeyAuthentication({
key: text(input.key),
response: input.response,
session: {
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
},
});
if (result.code === "mfa-required") return json(result, 401);
if (!result.ok || !result.session || !result.user) return json(result, 400);
establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) return onSignedIn(ctx);
return json(result);
},
};
}
+103
View File
@@ -0,0 +1,103 @@
export { createAuthEngine, type AuthEngine } from "./engine.ts";
export type { AuthStore } from "./store.ts";
export { MemoryAuthStore } from "./stores/memory.ts";
export { SqlAuthStore } from "./stores/sql.ts";
export {
authSession,
requireAuth,
establishAuthSession,
clearAuthSession,
getAuthUser,
getAuthSession,
isAuthenticatedContext,
AUTH_SESSION_KEY,
} from "./middleware.ts";
export {
createAuthHttpHandlers,
type AuthHttpOptions,
type AuthPasskeyHttpOptions,
} from "./http/index.ts";
export {
authPlugin,
authComponentsDir,
type AuthConfig,
type AuthRoutesConfig,
type AuthPluginOptions,
type AuthAuditIssue,
} from "./plugin.ts";
export {
setDefaultAuthEngine,
clearDefaultAuthEngine,
setDefaultAuthSchemas,
setDefaultAuthRouteOptions,
tryGetDefaultAuthEngine,
getDefaultAuthEngine,
getDefaultAuthSchemas,
getDefaultAuthRouteOptions,
hasDefaultAuthEngine,
type DefaultAuthRouteOptions,
} from "./runtime.ts";
export { createAuthSecretProtector } from "./protector.ts";
export { evaluateAuthRisk, type RiskPolicy } from "./risk.ts";
export {
normalizeEmail,
normalizePhone,
normalizeUsername,
normalizeIdentity,
inferIdentityType,
publicUser,
safeAuthReturnTo,
} from "./normalize.ts";
export {
generateTotpSecret,
generateTotp,
verifyTotp,
totpUri,
encodeBase32,
decodeBase32,
} from "./totp/index.ts";
export {
MemoryPasskeyChallengeStore,
assertPasskeyProvider,
type PasskeyChallengeStore,
type PasskeyChallengeRecord,
type PasskeyChallengeKind,
} from "./passkeys/index.ts";
export * from "./types.ts";
export {
registerSchema,
signUpSchema,
loginSchema,
verificationRequestSchema,
verificationTokenSchema,
passwordResetRequestSchema,
passwordResetSchema,
invitationAcceptSchema,
magicLinkRequestSchema,
magicLinkConsumeSchema,
otpLoginRequestSchema,
otpLoginCompleteSchema,
otpIssueSchema,
otpSchema,
mfaOtpRequestSchema,
mfaSchema,
sessionRevokeSchema,
impersonationStartSchema,
passkeyRegistrationOptionsSchema,
passkeyRegistrationVerifySchema,
passkeyAuthenticationOptionsSchema,
passkeyAuthenticationVerifySchema,
authenticatorSetupSchema,
authenticatorConfirmSchema,
authenticatorDisableSchema,
recoveryCodesSchema,
emptyActionSchema,
changePasswordSchema,
authSchemas,
authBrowserSchemaMap,
authBrowserSchemaDescriptors,
resolveAuthSchemas,
type AuthSchemaSet,
type AuthSchemaOverrides,
} from "./validation.ts";
+109
View File
@@ -0,0 +1,109 @@
import type { Context, Middleware } from "@wrnexus/core";
import type { AuthEngine } from "./engine.ts";
import { publicUser } from "./normalize.ts";
import type { AuthenticatedContext, AuthPublicUser, AuthSession } from "./types.ts";
export const AUTH_SESSION_KEY = "wrnexus.auth.session";
function wantsJson(ctx: Context): boolean {
if (ctx.url.pathname.startsWith("/api/") || ctx.url.pathname.startsWith("/__wrnexus/"))
return true;
const accept = ctx.req.headers.get("accept") ?? "";
return accept.includes("application/json") && !accept.includes("text/html");
}
export function authSession(engine: AuthEngine): Middleware {
return async (ctx, next) => {
const sessionId = ctx.session.get<string>(AUTH_SESSION_KEY);
if (!sessionId) {
ctx.user = null;
ctx.locals.authUser = null;
delete ctx.locals.authSession;
return next();
}
const session = await engine.validateSession(sessionId);
if (!session) {
ctx.session.delete(AUTH_SESSION_KEY);
ctx.user = null;
ctx.locals.authUser = null;
delete ctx.locals.authSession;
return next();
}
const user = await engine.getUser(session.userId);
if (!user || user.status !== "active") {
ctx.session.delete(AUTH_SESSION_KEY);
ctx.user = null;
ctx.locals.authUser = null;
delete ctx.locals.authSession;
return next();
}
const safe = publicUser(user);
ctx.user = safe;
ctx.locals.authUser = safe;
ctx.locals.authSession = session;
return next();
};
}
export function establishAuthSession(
ctx: Context,
session: AuthSession,
user: AuthPublicUser,
): void {
ctx.session.regenerate();
ctx.session.set(AUTH_SESSION_KEY, session.id);
ctx.user = user;
ctx.locals.authUser = user;
ctx.locals.authSession = session;
}
export function clearAuthSession(ctx: Context): void {
ctx.session.delete(AUTH_SESSION_KEY);
ctx.user = null;
delete ctx.locals.authUser;
delete ctx.locals.authSession;
}
export function getAuthUser(ctx: Context): AuthPublicUser | null {
return (
(ctx.locals.authUser as AuthPublicUser | null | undefined) ??
(ctx.user as AuthPublicUser | null | undefined) ??
null
);
}
export function getAuthSession(ctx: Context): AuthSession | null {
return (ctx.locals.authSession as AuthSession | undefined) ?? null;
}
export interface RequireAuthOptions {
loginPath?: string;
returnToParam?: string;
roles?: string[];
status?: AuthPublicUser["status"][];
}
export function requireAuth(options: RequireAuthOptions = {}): Middleware {
const loginPath = options.loginPath ?? "/sign-in";
const returnToParam = options.returnToParam ?? "returnTo";
return (ctx, next) => {
const user = getAuthUser(ctx);
const allowedStatus = options.status ?? ["active"];
const allowedRole =
!options.roles?.length || options.roles.some((role) => user?.roles.includes(role));
if (user && allowedStatus.includes(user.status) && allowedRole) return next();
if (wantsJson(ctx)) {
return Response.json(
{ ok: false, error: user ? "Forbidden" : "Unauthorized" },
{ status: user ? 403 : 401 },
);
}
const redirect = new URL(loginPath, ctx.url);
redirect.searchParams.set(returnToParam, `${ctx.url.pathname}${ctx.url.search}`);
return Response.redirect(redirect, 302);
};
}
export function isAuthenticatedContext(ctx: Context): ctx is AuthenticatedContext {
return Boolean(getAuthUser(ctx));
}
+55
View File
@@ -0,0 +1,55 @@
import type { AuthIdentityType, AuthPublicUser, AuthUser } from "./types.ts";
export function normalizeEmail(value: string): string {
return value.trim().toLowerCase();
}
export function normalizeUsername(value: string): string {
return value.trim().toLowerCase();
}
export function normalizePhone(value: string): string {
const trimmed = value.trim();
const prefix = trimmed.startsWith("+") ? "+" : "";
return prefix + trimmed.replace(/\D/g, "");
}
export function normalizeIdentity(type: AuthIdentityType, value: string): string {
if (type === "email") return normalizeEmail(value);
if (type === "phone") return normalizePhone(value);
return normalizeUsername(value);
}
export function inferIdentityType(value: string): AuthIdentityType {
const input = value.trim();
if (input.includes("@")) return "email";
if (/^\+?[0-9 ()-]{7,}$/.test(input)) return "phone";
return "username";
}
export function publicUser(user: AuthUser): AuthPublicUser {
return {
id: user.id,
username: user.username,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
status: user.status,
roles: [...user.roles],
emailVerified: user.emailVerified,
phoneVerified: user.phoneVerified,
mfaEnabled: user.mfaEnabled,
};
}
/** Return a same-origin path for post-authentication navigation. */
export function safeAuthReturnTo(value: string | undefined, origin: string): string | undefined {
if (!value) return undefined;
try {
const base = new URL(origin);
const target = new URL(value, base);
if (target.origin !== base.origin) return undefined;
return `${target.pathname}${target.search}${target.hash}`;
} catch {
return undefined;
}
}
+100
View File
@@ -0,0 +1,100 @@
import type {
PasskeyAuthenticationOptions,
PasskeyProvider,
PasskeyRegistrationOptions,
} from "../types.ts";
export type { PasskeyProvider } from "../types.ts";
export type PasskeyChallengeKind = "registration" | "authentication";
export interface PasskeyChallengeRecord {
challenge: string;
kind: PasskeyChallengeKind;
userId?: string;
rpId: string;
origin: string;
expiresAt: number;
}
/**
* Durable challenge storage contract. Production deployments with more than one
* process should provide a shared implementation (for example Redis or SQL).
*/
export interface PasskeyChallengeStore {
set(key: string, value: PasskeyChallengeRecord): Promise<void>;
consume(key: string): Promise<PasskeyChallengeRecord | undefined>;
}
export class MemoryPasskeyChallengeStore implements PasskeyChallengeStore {
private readonly values = new Map<string, PasskeyChallengeRecord>();
private readonly now: () => number;
constructor(now: () => number = () => Date.now()) {
this.now = now;
}
private pruneExpired(): void {
const timestamp = this.now();
for (const [key, value] of this.values) {
if (value.expiresAt <= timestamp) this.values.delete(key);
}
}
async set(key: string, value: PasskeyChallengeRecord): Promise<void> {
this.pruneExpired();
this.values.set(key, { ...value });
}
async consume(key: string): Promise<PasskeyChallengeRecord | undefined> {
this.pruneExpired();
const value = this.values.get(key);
this.values.delete(key);
return value ? { ...value } : undefined;
}
}
export function assertPasskeyProvider(provider: PasskeyProvider | undefined): PasskeyProvider {
if (!provider) throw new Error("WRN-AUTH-PASSKEY-PROVIDER: configure a PasskeyProvider");
return provider;
}
export function publicKeyCreationOptions(
options: PasskeyRegistrationOptions,
): PublicKeyCredentialCreationOptions {
return {
...options,
challenge: decode(options.challenge),
user: { ...options.user, id: decode(options.user.id) },
excludeCredentials: options.excludeCredentials?.map((item) => ({
...item,
id: decode(item.id),
transports: item.transports as AuthenticatorTransport[] | undefined,
})),
} as unknown as PublicKeyCredentialCreationOptions;
}
export function publicKeyRequestOptions(
options: PasskeyAuthenticationOptions,
): PublicKeyCredentialRequestOptions {
return {
...options,
challenge: decode(options.challenge),
allowCredentials: options.allowCredentials?.map((item) => ({
...item,
id: decode(item.id),
transports: item.transports as AuthenticatorTransport[] | undefined,
})),
} as unknown as PublicKeyCredentialRequestOptions;
}
function decode(value: string): Uint8Array {
if (!value || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) {
throw new TypeError("Passkey data must be canonical Base64URL without padding");
}
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(normalized + "=".repeat((4 - (normalized.length % 4)) % 4));
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
}
+414
View File
@@ -0,0 +1,414 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
import type { AuthEngine } from "./engine.ts";
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
import type { AuthSignedInHandler, AuthSignedOutHandler } from "./types.ts";
import { AUTH_ROUTE_DEFINITIONS, type AuthRouteGroup } from "./routes/definitions.ts";
import {
clearDefaultAuthEngine,
setDefaultAuthEngine,
setDefaultAuthRouteOptions,
setDefaultAuthSchemas,
} from "./runtime.ts";
import {
authBrowserSchemaDescriptors,
resolveAuthSchemas,
type AuthSchemaOverrides,
type AuthSchemaSet,
} from "./validation.ts";
export interface AuthRoutesConfig {
enabled?: boolean;
registration?: boolean;
login?: boolean;
verification?: boolean;
password?: boolean;
invitations?: boolean;
magicLink?: boolean;
otp?: boolean;
mfa?: boolean;
sessions?: boolean;
impersonation?: boolean;
passkeys?: boolean;
}
export interface AuthConfig {
enabled?: boolean;
engine?: AuthEngine;
routes?: boolean | AuthRoutesConfig;
migrations?: boolean;
middleware?: boolean;
components?: boolean;
client?: boolean;
devToolbar?: boolean;
componentDir?: string;
schemas?: AuthSchemaOverrides;
baseUrl?: string;
csrf?: boolean;
passkey?: AuthPasskeyHttpOptions;
/** @deprecated Prefer createAuthEngine({ onSignedIn }). */
onSignedIn?: AuthSignedInHandler;
/** @deprecated Prefer createAuthEngine({ onSignedOut }). */
onSignedOut?: AuthSignedOutHandler;
}
/** Explicit plugin options remain supported for compatibility. Prefer config.auth. */
export interface AuthPluginOptions {
componentDir?: string;
exposeComponentDirectory?: boolean;
enableDevToolbar?: boolean;
includeMigrations?: boolean;
includeRoutes?: boolean;
includeMiddleware?: boolean;
}
export interface AuthAuditIssue {
id: string;
severity: "error" | "warning" | "suggestion";
title: string;
message: string;
file: string;
}
interface ResolvedAuthConfig {
enabled: boolean;
engine?: AuthEngine;
routes: boolean | AuthRoutesConfig;
migrations: boolean;
middleware: boolean;
components: boolean;
client: boolean;
devToolbar: boolean;
componentDir: string;
schemas: AuthSchemaSet;
baseUrl?: string;
csrf: boolean;
passkey?: AuthPasskeyHttpOptions;
onSignedIn?: AuthSignedInHandler;
onSignedOut?: AuthSignedOutHandler;
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const clientRuntime = join(packageRoot, "assets", "client", "auth.js");
const migrationsFile = join(packageRoot, "migrations", "001_auth.sql");
const otpPurposeMigrationFile = join(packageRoot, "migrations", "002_auth_otp_purpose.sql");
const apiRoutesDir = join(packageRoot, "src", "routes", "api");
const middlewareFile = join(packageRoot, "src", "routes", "middleware.ts");
const resolvedConfigKey = "@wrnexus/auth:resolved-config";
export function authComponentsDir(): string {
return join(packageRoot, "components");
}
function authApiRouteEntry(path: string): string {
const routeName = path.replace(/^\/api\/auth\/?/, "").replace(/\//g, "-") || "index";
return join(apiRoutesDir, routeName + ".ts");
}
function resolveConfig(
config: Record<string, unknown>,
options: AuthPluginOptions,
): ResolvedAuthConfig {
const raw = (config.auth ?? {}) as AuthConfig;
const enabled = raw.enabled !== false;
const hasEngine = Boolean(raw.engine);
const hasDefaultDb = Boolean(config.db);
return {
enabled,
engine: raw.engine,
routes: options.includeRoutes !== undefined ? options.includeRoutes : (raw.routes ?? hasEngine),
migrations:
options.includeMigrations !== undefined
? options.includeMigrations
: (raw.migrations ?? (hasEngine && hasDefaultDb)),
middleware:
options.includeMiddleware !== undefined
? options.includeMiddleware
: (raw.middleware ?? hasEngine),
components:
options.exposeComponentDirectory !== undefined
? options.exposeComponentDirectory
: (raw.components ?? true),
client: raw.client ?? true,
devToolbar:
options.enableDevToolbar !== undefined ? options.enableDevToolbar : (raw.devToolbar ?? true),
componentDir: options.componentDir ?? raw.componentDir ?? authComponentsDir(),
schemas: resolveAuthSchemas(raw.schemas),
baseUrl: raw.baseUrl,
csrf: raw.csrf ?? true,
passkey: raw.passkey,
onSignedIn: raw.onSignedIn ?? raw.engine?.onSignedIn,
onSignedOut: raw.onSignedOut ?? raw.engine?.onSignedOut,
};
}
function fallbackConfig(options: AuthPluginOptions): ResolvedAuthConfig {
return {
enabled: true,
// Automatic package discovery must never expose authentication
// endpoints unless the application configures auth or the developer
// explicitly enables the routes through plugin options.
routes: options.includeRoutes ?? false,
migrations: options.includeMigrations ?? false,
middleware: options.includeMiddleware ?? false,
// Components and browser assets are safe to expose automatically.
components: options.exposeComponentDirectory ?? true,
client: true,
devToolbar: options.enableDevToolbar ?? true,
componentDir: options.componentDir ?? authComponentsDir(),
schemas: resolveAuthSchemas(),
csrf: true,
};
}
function resolved(context: PluginContext, options: AuthPluginOptions): ResolvedAuthConfig {
return (
(context.metadata.get(resolvedConfigKey) as ResolvedAuthConfig | undefined) ??
fallbackConfig(options)
);
}
function routeEnabled(routes: boolean | AuthRoutesConfig, group: AuthRouteGroup): boolean {
if (typeof routes === "boolean") return routes;
if (routes.enabled === false) return false;
return routes[group] !== false;
}
function authClientSource(schemas: AuthSchemaSet): string {
const descriptors = JSON.stringify(authBrowserSchemaDescriptors(schemas));
const runtime = readFileSync(clientRuntime, "utf8");
return `
(function () {
var defaults = ${descriptors};
window.__wireSchemas =
window.__wireSchemas || {};
Object.keys(defaults).forEach(
function (name) {
if (!(name in window.__wireSchemas)) {
window.__wireSchemas[name] =
defaults[name];
}
}
);
if (
window.__wireValidate &&
typeof window.__wireValidate
.registerSchemas === "function"
) {
window.__wireValidate.registerSchemas(
defaults,
document
);
} else if (
window.__wireValidate &&
typeof window.__wireValidate.init ===
"function"
) {
window.__wireValidate.init(document);
}
})();
${runtime}
`;
}
function audit(code: string, file: string): AuthAuditIssue[] {
const issues: AuthAuditIssue[] = [];
const push = (id: string, severity: AuthAuditIssue["severity"], title: string, message: string) =>
issues.push({ id: `${id}:${file}`, severity, title, message, file });
if (/<SignIn|<SignUp|data-component=["']SignIn|data-component=["']SignUp/.test(code)) {
if (!/<Captcha|captchaGuard|captchaPageGate/.test(code)) {
push(
"captcha-escalation",
"suggestion",
"Add adaptive CAPTCHA",
"Authentication forms should connect suspicious attempts to @wrnexus/captcha.",
);
}
if (!/autocomplete=/.test(code)) {
push(
"autocomplete",
"warning",
"Credential autocomplete is missing",
"Use username, current-password, and new-password autocomplete values.",
);
}
}
if (/secret\s*=|clientSecret\s*=|privateKey\s*=/.test(code) && /\.wrn$/.test(file)) {
push(
"client-secret",
"error",
"Authentication secret exposed",
"Never pass server secrets to a .wrn component.",
);
}
if (
/returnTo|redirect/.test(code) &&
!/validateOAuthReturnTo|safeReturnTo|startsWith\(["']\//.test(code)
) {
push(
"open-redirect",
"suggestion",
"Confirm redirects are same-origin",
"Validate returnTo values before redirecting after sign-in.",
);
}
return issues;
}
export function authPlugin(options: AuthPluginOptions = {}) {
const metadataKey = "@wrnexus/auth:audit";
return definePlugin({
name: "@wrnexus/auth",
version: "0.5.0",
enforce: "post",
componentDirs(context) {
const value = resolved(context, options);
return value.enabled && value.components ? [value.componentDir] : [];
},
clientRuntimes(context) {
const value = resolved(context, options);
if (!value.enabled || !value.client) return [];
return [
{
id: "auth",
source: authClientSource(value.schemas),
type: "script" as const,
load: "defer" as const,
singleton: true,
bundle: false,
},
];
},
styleSources(context) {
const value = resolved(context, options);
return value.enabled && value.components
? [{ id: "auth-components", source: value.componentDir, order: "normal" as const }]
: [];
},
routeEntries(context) {
const value = resolved(context, options);
if (!value.enabled) {
return [];
}
return AUTH_ROUTE_DEFINITIONS.filter((route) => routeEnabled(value.routes, route.group)).map(
({ path }) => ({
kind: "api" as const,
path,
entry: authApiRouteEntry(path),
}),
);
},
middleware(context) {
const value = resolved(context, options);
return value.enabled && value.middleware ? [middlewareFile] : [];
},
migrations(context) {
const value = resolved(context, options);
return value.enabled && value.migrations
? [
{
id: "wrnexus-auth-001",
source: readFileSync(migrationsFile, "utf8"),
},
{
id: "wrnexus-auth-002-otp-purpose",
source: readFileSync(otpPurposeMigrationFile, "utf8"),
},
]
: [];
},
configure(config, context) {
const value = resolveConfig(config, options);
context.metadata.set(resolvedConfigKey, value);
config.auth = {
...((config.auth ?? {}) as Record<string, unknown>),
componentDir: value.componentDir,
schemas: value.schemas,
};
if (value.engine) {
setDefaultAuthEngine(value.engine);
} else {
clearDefaultAuthEngine();
}
setDefaultAuthSchemas(value.schemas);
setDefaultAuthRouteOptions({
baseUrl: value.baseUrl,
csrf: value.csrf,
passkey: value.passkey,
onSignedIn: value.onSignedIn,
onSignedOut: value.onSignedOut,
});
context.metadata.set("@wrnexus/auth:component-dir", value.componentDir);
context.metadata.set("@wrnexus/auth:configured", Boolean(value.engine));
},
transformCode(code, context) {
if (context.mode !== "development") return;
const previous = (context.metadata.get(metadataKey) as AuthAuditIssue[] | undefined) ?? [];
context.metadata.set(metadataKey, [
...previous.filter((issue) => issue.file !== context.file),
...audit(code, context.file),
]);
},
devToolbarPanels(context) {
const value = resolved(context, options);
if (!value.enabled || !value.devToolbar) return [];
const issues = (context.metadata.get(metadataKey) as AuthAuditIssue[] | undefined) ?? [];
return [
{
id: "wrnexus-auth",
title: "Authentication",
icon: "shield-user",
badge: issues.length,
description: "Authentication security, session, passkey, and recovery checks",
issues,
},
];
},
});
}
export default authPlugin;
+39
View File
@@ -0,0 +1,39 @@
import { open, seal, type EncryptionKeyring } from "@wrnexus/encryption";
import type { AuthSecretProtector } from "./types.ts";
const PURPOSE_PREFIX = "wrn-auth-secret:v1:";
type AuthSecretPurpose = "totp" | "oauth-access" | "oauth-refresh";
function bindPurpose(value: string, purpose: AuthSecretPurpose): string {
return `${PURPOSE_PREFIX}${purpose}\0${value}`;
}
function revealBoundValue(value: string, purpose: AuthSecretPurpose): string {
if (!value.startsWith(PURPOSE_PREFIX)) {
// Backward compatibility for ciphertext written before purpose binding was introduced.
return value;
}
const separator = value.indexOf("\0", PURPOSE_PREFIX.length);
if (separator < 0) throw new Error("WRN-AUTH-SECRET-PAYLOAD");
const storedPurpose = value.slice(PURPOSE_PREFIX.length, separator);
if (storedPurpose !== purpose) throw new Error("WRN-AUTH-SECRET-PURPOSE");
return value.slice(separator + 1);
}
/**
* Protect TOTP and OAuth secrets with the versioned @wrnexus/encryption keyring.
* Rotated keys continue to decrypt old records while new writes use the active key.
* New payloads are bound to their purpose so encrypted values cannot be swapped
* between TOTP, OAuth access-token, and OAuth refresh-token fields.
*/
export function createAuthSecretProtector(keyring: EncryptionKeyring): AuthSecretProtector {
return {
async protect(value, purpose) {
return seal(bindPurpose(value, purpose), keyring);
},
async reveal(value, purpose) {
return revealBoundValue(await open(value, keyring), purpose);
},
};
}
+60
View File
@@ -0,0 +1,60 @@
import type { AuthRiskDecision, AuthRiskSignals, AuthRiskLevel } from "./types.ts";
export interface RiskPolicy {
captchaThreshold: number;
mfaThreshold: number;
blockThreshold: number;
}
function finiteScore(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function threshold(value: number, fallback: number): number {
return Math.min(100, Math.max(0, finiteScore(value, fallback)));
}
function level(score: number): AuthRiskLevel {
if (score >= 90) return "critical";
if (score >= 65) return "high";
if (score >= 35) return "medium";
return "low";
}
export function evaluateAuthRisk(
signals: AuthRiskSignals = {},
policy: RiskPolicy = { captchaThreshold: 35, mfaThreshold: 60, blockThreshold: 90 },
): AuthRiskDecision {
let score = Math.max(0, finiteScore(signals.customScore));
const reasons: string[] = [];
const add = (condition: boolean | undefined, points: number, reason: string) => {
if (!condition) return;
score += points;
reasons.push(reason);
};
const failedAttempts = Math.max(0, Math.floor(finiteScore(signals.failedAttempts)));
if (failedAttempts > 0) {
score += Math.min(45, failedAttempts * 10);
reasons.push("failed-attempts");
}
add(signals.unfamiliarDevice, 18, "unfamiliar-device");
add(signals.unusualIp, 20, "unusual-ip");
add(signals.impossibleTravel, 35, "impossible-travel");
add(signals.breachedPassword, 45, "breached-password");
add(signals.automationSuspected, 40, "automation-suspected");
add(signals.accountLocked, 100, "account-locked");
score = Math.min(100, Math.max(0, score));
const captchaThreshold = threshold(policy.captchaThreshold, 35);
const mfaThreshold = threshold(policy.mfaThreshold, 60);
const blockThreshold = threshold(policy.blockThreshold, 90);
return {
score,
level: level(score),
requireCaptcha: score >= captchaThreshold,
requireMfa: score >= mfaThreshold,
block: score >= blockThreshold,
reasons,
};
}
+125
View File
@@ -0,0 +1,125 @@
import { verifyCsrf, type Context } from "@wrnexus/core";
import { createAuthHttpHandlers } from "../http/index.ts";
import {
getDefaultAuthEngine,
getDefaultAuthRouteOptions,
getDefaultAuthSchemas,
hasDefaultAuthEngine,
} from "../runtime.ts";
export type AuthHttpHandlers = ReturnType<typeof createAuthHttpHandlers>;
import { AUTH_ROUTE_DEFINITIONS, type AuthHandlerName } from "./definitions.ts";
const ROUTES = Object.fromEntries(
AUTH_ROUTE_DEFINITIONS.map((definition) => [definition.path, definition]),
) as Readonly<Record<string, (typeof AUTH_ROUTE_DEFINITIONS)[number]>>;
function unavailable(): Response {
return Response.json(
{
ok: false,
error: "WRN-AUTH-NOT-CONFIGURED",
message: "Configure auth.engine before serving package auth routes.",
},
{ status: 503, headers: { "cache-control": "no-store" } },
);
}
function normalizeRoutePath(value: string): string {
try {
return (decodeURIComponent(value).replace(/\/+$/, "") || "/").toLowerCase();
} catch {
return (value.replace(/\/+$/, "") || "/").toLowerCase();
}
}
function requestRoutePath(ctx: Context): string {
const matchedRoute = ctx.locals.__wrnexusRoute;
if (typeof matchedRoute === "string" && matchedRoute.startsWith("/api/auth/")) {
return normalizeRoutePath(matchedRoute);
}
try {
return normalizeRoutePath(new URL(ctx.req.url).pathname);
} catch {
return normalizeRoutePath(ctx.url.pathname);
}
}
function methodNotAllowed(allowed: readonly string[]): Response {
return Response.json(
{ ok: false, error: "Method Not Allowed" },
{ status: 405, headers: { allow: allowed.join(", "), "cache-control": "no-store" } },
);
}
function handlersFor(ctx: Context): AuthHttpHandlers | undefined {
if (!hasDefaultAuthEngine()) return undefined;
const routeOptions = getDefaultAuthRouteOptions();
return createAuthHttpHandlers({
engine: getDefaultAuthEngine(),
schemas: getDefaultAuthSchemas(),
baseUrl: routeOptions.baseUrl ?? ctx.url.origin,
passkey: routeOptions.passkey,
onSignedIn: routeOptions.onSignedIn,
onSignedOut: routeOptions.onSignedOut,
});
}
/** Invoke one concrete handler. Route-specific entry modules use this path. */
export async function invokeAuthHandler(name: AuthHandlerName, ctx: Context): Promise<Response> {
const handlers = handlersFor(ctx);
if (!handlers) return unavailable();
const method = ctx.req.method.toUpperCase();
const unsafeMethod = method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
if (unsafeMethod && getDefaultAuthRouteOptions().csrf !== false && !verifyCsrf(ctx)) {
return Response.json(
{ ok: false, error: "Invalid CSRF token" },
{ status: 403, headers: { "cache-control": "no-store" } },
);
}
try {
return await handlers[name](ctx);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.startsWith("WRN-AUTH-PASSKEY-PROVIDER:")) {
return Response.json(
{
ok: false,
error: "Passkeys are unavailable",
code: "passkey-provider-not-configured",
},
{ status: 503, headers: { "cache-control": "no-store" } },
);
}
console.error(`[wrnexus:auth] ${name} failed`, error);
return Response.json(
{ ok: false, error: "Authentication request failed" },
{ status: 500, headers: { "cache-control": "no-store" } },
);
}
}
/** Backward-compatible dispatcher for applications importing the shared route. */
export async function dispatchAuthRoute(routePath: string, ctx: Context): Promise<Response> {
const definition = ROUTES[normalizeRoutePath(routePath)];
if (!definition) {
return Response.json(
{ ok: false, error: "Not Found" },
{ status: 404, headers: { "cache-control": "no-store" } },
);
}
const method = ctx.req.method.toUpperCase();
if (!definition.methods.some((allowed) => allowed === method)) {
return methodNotAllowed(definition.methods);
}
return invokeAuthHandler(definition.handler, ctx);
}
export default async function authApi(ctx: Context): Promise<Response> {
return dispatchAuthRoute(requestRoutePath(ctx), ctx);
}
export const GET = authApi;
export const POST = authApi;
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("startImpersonation", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("stopImpersonation", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("acceptInvitation", ctx);
}
+6
View File
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("login", ctx);
}
+6
View File
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("logout", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("requestMagicLink", ctx);
}
@@ -0,0 +1,10 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function GET(ctx: Context): Promise<Response> {
return invokeAuthHandler("consumeMagicLink", ctx);
}
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("consumeMagicLink", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("completeMfa", ctx);
}
+6
View File
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("beginMfaOtp", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("completeOtpLogin", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("requestOtpLogin", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("verifyOtp", ctx);
}
+6
View File
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("issueOtp", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("passkeyAuthenticationOptions", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("passkeyAuthenticationVerify", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("passkeyRegistrationOptions", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("passkeyRegistrationVerify", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("changePassword", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("requestPasswordReset", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("resetPassword", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("recoveryCodes", ctx);
}
+6
View File
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("register", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("revokeSession", ctx);
}
+6
View File
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function GET(ctx: Context): Promise<Response> {
return invokeAuthHandler("sessions", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("confirmTotp", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("disableTotp", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("beginTotp", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("requestVerification", ctx);
}
@@ -0,0 +1,10 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function GET(ctx: Context): Promise<Response> {
return invokeAuthHandler("verifyEmail", ctx);
}
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("verifyEmail", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("verifyPhone", ctx);
}
+171
View File
@@ -0,0 +1,171 @@
import type { createAuthHttpHandlers } from "../http/index.ts";
export type AuthHandlerName = keyof ReturnType<typeof createAuthHttpHandlers>;
export type AuthRouteGroup =
| "registration"
| "login"
| "verification"
| "password"
| "invitations"
| "magicLink"
| "otp"
| "mfa"
| "sessions"
| "impersonation"
| "passkeys";
export interface AuthRouteDefinition {
path: string;
group: AuthRouteGroup;
handler: AuthHandlerName;
methods: readonly ("GET" | "POST")[];
}
/** Single source of truth for package-contributed auth endpoints. */
export const AUTH_ROUTE_DEFINITIONS = [
{ path: "/api/auth/register", group: "registration", handler: "register", methods: ["POST"] },
{ path: "/api/auth/login", group: "login", handler: "login", methods: ["POST"] },
{ path: "/api/auth/logout", group: "login", handler: "logout", methods: ["POST"] },
{
path: "/api/auth/verification/request",
group: "verification",
handler: "requestVerification",
methods: ["POST"],
},
{
path: "/api/auth/verify/email",
group: "verification",
handler: "verifyEmail",
methods: ["GET", "POST"],
},
{
path: "/api/auth/verify/phone",
group: "verification",
handler: "verifyPhone",
methods: ["POST"],
},
{
path: "/api/auth/password/request",
group: "password",
handler: "requestPasswordReset",
methods: ["POST"],
},
{
path: "/api/auth/password/reset",
group: "password",
handler: "resetPassword",
methods: ["POST"],
},
{
path: "/api/auth/password/change",
group: "password",
handler: "changePassword",
methods: ["POST"],
},
{
path: "/api/auth/invitations/accept",
group: "invitations",
handler: "acceptInvitation",
methods: ["POST"],
},
{
path: "/api/auth/magic-link/request",
group: "magicLink",
handler: "requestMagicLink",
methods: ["POST"],
},
{
path: "/api/auth/magic-link",
group: "magicLink",
handler: "consumeMagicLink",
methods: ["GET", "POST"],
},
{
path: "/api/auth/otp/login/request",
group: "otp",
handler: "requestOtpLogin",
methods: ["POST"],
},
{
path: "/api/auth/otp/login/complete",
group: "otp",
handler: "completeOtpLogin",
methods: ["POST"],
},
{ path: "/api/auth/otp", group: "otp", handler: "issueOtp", methods: ["POST"] },
{
path: "/api/auth/otp/verify",
group: "otp",
handler: "verifyOtp",
methods: ["POST"],
},
{ path: "/api/auth/totp/setup", group: "mfa", handler: "beginTotp", methods: ["POST"] },
{
path: "/api/auth/totp/confirm",
group: "mfa",
handler: "confirmTotp",
methods: ["POST"],
},
{
path: "/api/auth/totp/disable",
group: "mfa",
handler: "disableTotp",
methods: ["POST"],
},
{
path: "/api/auth/recovery-codes",
group: "mfa",
handler: "recoveryCodes",
methods: ["POST"],
},
{ path: "/api/auth/mfa/otp", group: "mfa", handler: "beginMfaOtp", methods: ["POST"] },
{
path: "/api/auth/mfa/complete",
group: "mfa",
handler: "completeMfa",
methods: ["POST"],
},
{ path: "/api/auth/sessions", group: "sessions", handler: "sessions", methods: ["GET"] },
{
path: "/api/auth/sessions/revoke",
group: "sessions",
handler: "revokeSession",
methods: ["POST"],
},
{
path: "/api/auth/impersonation/start",
group: "impersonation",
handler: "startImpersonation",
methods: ["POST"],
},
{
path: "/api/auth/impersonation/stop",
group: "impersonation",
handler: "stopImpersonation",
methods: ["POST"],
},
{
path: "/api/auth/passkeys/register/options",
group: "passkeys",
handler: "passkeyRegistrationOptions",
methods: ["POST"],
},
{
path: "/api/auth/passkeys/register/verify",
group: "passkeys",
handler: "passkeyRegistrationVerify",
methods: ["POST"],
},
{
path: "/api/auth/passkeys/login/options",
group: "passkeys",
handler: "passkeyAuthenticationOptions",
methods: ["POST"],
},
{
path: "/api/auth/passkeys/login/verify",
group: "passkeys",
handler: "passkeyAuthenticationVerify",
methods: ["POST"],
},
] as const satisfies readonly AuthRouteDefinition[];
+11
View File
@@ -0,0 +1,11 @@
import type { Middleware } from "@wrnexus/core";
import { authSession } from "../middleware.ts";
import { getDefaultAuthEngine, hasDefaultAuthEngine } from "../runtime.ts";
/** Package middleware: hydrates auth state when a default engine is configured. */
const middleware: Middleware = async (ctx, next) => {
if (!hasDefaultAuthEngine()) return next();
return authSession(getDefaultAuthEngine())(ctx, next);
};
export default middleware;
+100
View File
@@ -0,0 +1,100 @@
import type { Context } from "@wrnexus/core";
import type { AuthEngine } from "./engine.ts";
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "./validation.ts";
export interface DefaultAuthRouteOptions {
baseUrl?: string;
csrf?: boolean;
passkey?: AuthPasskeyHttpOptions;
onSignedIn?: (ctx: Context, returnTo?: string) => Response | Promise<Response>;
onSignedOut?: (ctx: Context) => Response | Promise<Response>;
}
interface AuthRuntimeState {
engine?: AuthEngine;
schemas: AuthSchemaSet;
routeOptions: DefaultAuthRouteOptions;
}
/**
* A Symbol.for registry is used instead of module-local variables.
*
* The dev server may import the auth plugin and route modules using
* different module URLs during HMR. Those modules still execute inside
* the same JavaScript global realm, so Symbol.for keeps the runtime
* configuration shared between them.
*/
const AUTH_RUNTIME_STATE_KEY = Symbol.for("@wrnexus/auth:runtime-state:v1");
function runtimeState(): AuthRuntimeState {
const registry = globalThis as unknown as Record<PropertyKey, unknown>;
const existing = registry[AUTH_RUNTIME_STATE_KEY] as AuthRuntimeState | undefined;
if (existing) {
return existing;
}
const created: AuthRuntimeState = {
schemas: resolveAuthSchemas(),
routeOptions: {},
};
registry[AUTH_RUNTIME_STATE_KEY] = created;
return created;
}
export function setDefaultAuthEngine(engine: AuthEngine): void {
runtimeState().engine = engine;
}
/**
* Clear auth state when an application removes config.auth.engine
* during development or when another application starts in the
* same process.
*/
export function clearDefaultAuthEngine(): void {
delete runtimeState().engine;
}
export function setDefaultAuthSchemas(schemas: AuthSchemaOverrides | AuthSchemaSet = {}): void {
runtimeState().schemas = resolveAuthSchemas(schemas);
}
export function setDefaultAuthRouteOptions(options: DefaultAuthRouteOptions = {}): void {
runtimeState().routeOptions = {
...options,
};
}
export function tryGetDefaultAuthEngine(): AuthEngine | undefined {
return runtimeState().engine;
}
export function getDefaultAuthEngine(): AuthEngine {
const engine = runtimeState().engine;
if (!engine) {
throw new Error(
"WRN-AUTH-NOT-CONFIGURED: configure auth.engine or call setDefaultAuthEngine(createAuthEngine(...)) at startup",
);
}
return engine;
}
export function getDefaultAuthSchemas(): AuthSchemaSet {
return runtimeState().schemas;
}
export function getDefaultAuthRouteOptions(): DefaultAuthRouteOptions {
return runtimeState().routeOptions;
}
export function hasDefaultAuthEngine(): boolean {
return Boolean(runtimeState().engine);
}
+45
View File
@@ -0,0 +1,45 @@
export { createAuthEngine, type AuthEngine } from "../engine.ts";
export {
createAuthHttpHandlers,
type AuthHttpOptions,
type AuthPasskeyHttpOptions,
} from "../http/index.ts";
export {
authSession,
requireAuth,
establishAuthSession,
clearAuthSession,
getAuthUser,
getAuthSession,
isAuthenticatedContext,
AUTH_SESSION_KEY,
} from "../middleware.ts";
export {
setDefaultAuthEngine,
clearDefaultAuthEngine,
setDefaultAuthSchemas,
setDefaultAuthRouteOptions,
tryGetDefaultAuthEngine,
getDefaultAuthEngine,
getDefaultAuthSchemas,
getDefaultAuthRouteOptions,
hasDefaultAuthEngine,
type DefaultAuthRouteOptions,
} from "../runtime.ts";
export { MemoryAuthStore } from "../stores/memory.ts";
export { SqlAuthStore } from "../stores/sql.ts";
export * from "../types.ts";
export * from "../validation.ts";
export { createAuthSecretProtector } from "../protector.ts";
export {
normalizeEmail,
normalizePhone,
normalizeUsername,
normalizeIdentity,
inferIdentityType,
publicUser,
safeAuthReturnTo,
} from "../normalize.ts";
+83
View File
@@ -0,0 +1,83 @@
import type {
AuthIdentity,
AuthSecurityEvent,
AuthSession,
AuthUser,
LoginAttempt,
OAuthAccount,
OneTimeToken,
OtpChallenge,
PasskeyCredential,
PasswordCredential,
RecoveryCodeRecord,
TotpCredential,
TrustedDevice,
} from "./types.ts";
export interface AuthStore {
createUser(user: AuthUser): Promise<void>;
updateUser(user: AuthUser): Promise<void>;
findUserById(id: string): Promise<AuthUser | undefined>;
listUsers(): Promise<AuthUser[]>;
createIdentity(identity: AuthIdentity): Promise<void>;
updateIdentity(identity: AuthIdentity): Promise<void>;
findIdentity(
type: AuthIdentity["type"],
normalizedValue: string,
): Promise<AuthIdentity | undefined>;
listIdentities(userId: string): Promise<AuthIdentity[]>;
setPassword(credential: PasswordCredential): Promise<void>;
getPassword(userId: string): Promise<PasswordCredential | undefined>;
createSession(session: AuthSession): Promise<void>;
updateSession(session: AuthSession): Promise<void>;
findSession(id: string): Promise<AuthSession | undefined>;
listSessions(userId: string): Promise<AuthSession[]>;
deleteSession(id: string): Promise<void>;
createTrustedDevice(device: TrustedDevice): Promise<void>;
updateTrustedDevice(device: TrustedDevice): Promise<void>;
findTrustedDeviceByFingerprint(
userId: string,
fingerprintHash: string,
): Promise<TrustedDevice | undefined>;
listTrustedDevices(userId: string): Promise<TrustedDevice[]>;
createToken(token: OneTimeToken): Promise<void>;
updateToken(token: OneTimeToken): Promise<void>;
findTokenByHash(hash: string): Promise<OneTimeToken | undefined>;
createOtp(challenge: OtpChallenge): Promise<void>;
updateOtp(challenge: OtpChallenge): Promise<void>;
findOtp(id: string): Promise<OtpChallenge | undefined>;
createTotp(credential: TotpCredential): Promise<void>;
updateTotp(credential: TotpCredential): Promise<void>;
listTotp(userId: string): Promise<TotpCredential[]>;
deleteTotp(id: string): Promise<void>;
createRecoveryCodes(codes: RecoveryCodeRecord[]): Promise<void>;
updateRecoveryCode(code: RecoveryCodeRecord): Promise<void>;
listRecoveryCodes(userId: string): Promise<RecoveryCodeRecord[]>;
deleteRecoveryCodes(userId: string): Promise<void>;
createPasskey(credential: PasskeyCredential): Promise<void>;
updatePasskey(credential: PasskeyCredential): Promise<void>;
findPasskeyByCredentialId(credentialId: string): Promise<PasskeyCredential | undefined>;
listPasskeys(userId: string): Promise<PasskeyCredential[]>;
deletePasskey(id: string): Promise<void>;
createOAuthAccount(account: OAuthAccount): Promise<void>;
updateOAuthAccount(account: OAuthAccount): Promise<void>;
findOAuthAccount(provider: string, providerAccountId: string): Promise<OAuthAccount | undefined>;
listOAuthAccounts(userId: string): Promise<OAuthAccount[]>;
deleteOAuthAccount(id: string): Promise<void>;
createLoginAttempt(attempt: LoginAttempt): Promise<void>;
listRecentLoginAttempts(identifier: string, since: number): Promise<LoginAttempt[]>;
createSecurityEvent(event: AuthSecurityEvent): Promise<void>;
listSecurityEvents(userId: string, limit?: number): Promise<AuthSecurityEvent[]>;
}
+318
View File
@@ -0,0 +1,318 @@
import type { AuthStore } from "../store.ts";
import { inferIdentityType, normalizeIdentity } from "../normalize.ts";
import type {
AuthIdentity,
AuthSecurityEvent,
AuthSession,
AuthUser,
LoginAttempt,
OAuthAccount,
OneTimeToken,
OtpChallenge,
PasskeyCredential,
PasswordCredential,
RecoveryCodeRecord,
TotpCredential,
TrustedDevice,
} from "../types.ts";
function clone<T>(value: T): T {
return structuredClone(value);
}
export class MemoryAuthStore implements AuthStore {
private readonly users = new Map<string, AuthUser>();
private readonly identities = new Map<string, AuthIdentity>();
private readonly passwords = new Map<string, PasswordCredential>();
private readonly sessions = new Map<string, AuthSession>();
private readonly trustedDevices = new Map<string, TrustedDevice>();
private readonly tokens = new Map<string, OneTimeToken>();
private readonly otps = new Map<string, OtpChallenge>();
private readonly totp = new Map<string, TotpCredential>();
private readonly recoveryCodes = new Map<string, RecoveryCodeRecord>();
private readonly passkeys = new Map<string, PasskeyCredential>();
private readonly oauthAccounts = new Map<string, OAuthAccount>();
private readonly attempts: LoginAttempt[] = [];
private readonly events: AuthSecurityEvent[] = [];
async createUser(user: AuthUser): Promise<void> {
if (this.users.has(user.id)) throw new Error(`WRN-AUTH-USER-EXISTS: ${user.id}`);
this.users.set(user.id, clone(user));
}
async updateUser(user: AuthUser): Promise<void> {
if (!this.users.has(user.id)) throw new Error(`WRN-AUTH-USER-MISSING: ${user.id}`);
this.users.set(user.id, clone(user));
}
async findUserById(id: string): Promise<AuthUser | undefined> {
const value = this.users.get(id);
return value ? clone(value) : undefined;
}
async listUsers(): Promise<AuthUser[]> {
return [...this.users.values()].map(clone);
}
async createIdentity(identity: AuthIdentity): Promise<void> {
const key = `${identity.type}:${identity.normalizedValue}`;
if (
this.identities.has(key) ||
[...this.identities.values()].some((item) => item.id === identity.id)
) {
throw new Error("WRN-AUTH-IDENTITY-EXISTS");
}
this.identities.set(key, clone(identity));
}
async updateIdentity(identity: AuthIdentity): Promise<void> {
const currentEntry = [...this.identities.entries()].find(
([, existing]) => existing.id === identity.id,
);
if (!currentEntry) throw new Error("WRN-AUTH-IDENTITY-MISSING");
const [currentKey, current] = currentEntry;
if (identity.userId !== current.userId || identity.type !== current.type) {
throw new Error("WRN-AUTH-IDENTITY-IMMUTABLE");
}
const nextKey = `${identity.type}:${identity.normalizedValue}`;
const collision = this.identities.get(nextKey);
if (collision && collision.id !== identity.id) {
throw new Error("WRN-AUTH-IDENTITY-EXISTS");
}
if (currentKey !== nextKey) this.identities.delete(currentKey);
this.identities.set(nextKey, clone(identity));
}
async findIdentity(
type: AuthIdentity["type"],
normalizedValue: string,
): Promise<AuthIdentity | undefined> {
const value = this.identities.get(`${type}:${normalizedValue}`);
return value ? clone(value) : undefined;
}
async listIdentities(userId: string): Promise<AuthIdentity[]> {
return [...this.identities.values()].filter((item) => item.userId === userId).map(clone);
}
async setPassword(credential: PasswordCredential): Promise<void> {
this.passwords.set(credential.userId, clone(credential));
}
async getPassword(userId: string): Promise<PasswordCredential | undefined> {
const value = this.passwords.get(userId);
return value ? clone(value) : undefined;
}
async createSession(session: AuthSession): Promise<void> {
if (this.sessions.has(session.id)) throw new Error("WRN-AUTH-SESSION-EXISTS");
this.sessions.set(session.id, clone(session));
}
async updateSession(session: AuthSession): Promise<void> {
this.sessions.set(session.id, clone(session));
}
async findSession(id: string): Promise<AuthSession | undefined> {
const value = this.sessions.get(id);
return value ? clone(value) : undefined;
}
async listSessions(userId: string): Promise<AuthSession[]> {
return [...this.sessions.values()].filter((item) => item.userId === userId).map(clone);
}
async deleteSession(id: string): Promise<void> {
this.sessions.delete(id);
}
async createTrustedDevice(device: TrustedDevice): Promise<void> {
if (
this.trustedDevices.has(device.id) ||
[...this.trustedDevices.values()].some(
(item) => item.userId === device.userId && item.fingerprintHash === device.fingerprintHash,
)
) {
throw new Error("WRN-AUTH-TRUSTED-DEVICE-EXISTS");
}
this.trustedDevices.set(device.id, clone(device));
}
async updateTrustedDevice(device: TrustedDevice): Promise<void> {
this.trustedDevices.set(device.id, clone(device));
}
async findTrustedDeviceByFingerprint(
userId: string,
fingerprintHash: string,
): Promise<TrustedDevice | undefined> {
const value = [...this.trustedDevices.values()].find(
(item) => item.userId === userId && item.fingerprintHash === fingerprintHash,
);
return value ? clone(value) : undefined;
}
async listTrustedDevices(userId: string): Promise<TrustedDevice[]> {
return [...this.trustedDevices.values()].filter((item) => item.userId === userId).map(clone);
}
async createToken(token: OneTimeToken): Promise<void> {
if (
this.tokens.has(token.tokenHash) ||
[...this.tokens.values()].some((item) => item.id === token.id)
) {
throw new Error("WRN-AUTH-TOKEN-EXISTS");
}
this.tokens.set(token.tokenHash, clone(token));
}
async updateToken(token: OneTimeToken): Promise<void> {
this.tokens.set(token.tokenHash, clone(token));
}
async findTokenByHash(hash: string): Promise<OneTimeToken | undefined> {
const value = this.tokens.get(hash);
return value ? clone(value) : undefined;
}
async createOtp(challenge: OtpChallenge): Promise<void> {
if (this.otps.has(challenge.id)) throw new Error("WRN-AUTH-OTP-EXISTS");
this.otps.set(challenge.id, clone(challenge));
}
async updateOtp(challenge: OtpChallenge): Promise<void> {
this.otps.set(challenge.id, clone(challenge));
}
async findOtp(id: string): Promise<OtpChallenge | undefined> {
const value = this.otps.get(id);
return value ? clone(value) : undefined;
}
async createTotp(credential: TotpCredential): Promise<void> {
if (this.totp.has(credential.id)) throw new Error("WRN-AUTH-TOTP-EXISTS");
this.totp.set(credential.id, clone(credential));
}
async updateTotp(credential: TotpCredential): Promise<void> {
this.totp.set(credential.id, clone(credential));
}
async listTotp(userId: string): Promise<TotpCredential[]> {
return [...this.totp.values()].filter((item) => item.userId === userId).map(clone);
}
async deleteTotp(id: string): Promise<void> {
this.totp.delete(id);
}
async createRecoveryCodes(codes: RecoveryCodeRecord[]): Promise<void> {
const incoming = new Set<string>();
for (const code of codes) {
if (incoming.has(code.id) || this.recoveryCodes.has(code.id)) {
throw new Error("WRN-AUTH-RECOVERY-CODE-EXISTS");
}
incoming.add(code.id);
}
for (const code of codes) this.recoveryCodes.set(code.id, clone(code));
}
async updateRecoveryCode(code: RecoveryCodeRecord): Promise<void> {
this.recoveryCodes.set(code.id, clone(code));
}
async listRecoveryCodes(userId: string): Promise<RecoveryCodeRecord[]> {
return [...this.recoveryCodes.values()].filter((item) => item.userId === userId).map(clone);
}
async deleteRecoveryCodes(userId: string): Promise<void> {
for (const [id, code] of this.recoveryCodes) {
if (code.userId === userId) this.recoveryCodes.delete(id);
}
}
async createPasskey(credential: PasskeyCredential): Promise<void> {
if (
this.passkeys.has(credential.id) ||
[...this.passkeys.values()].some((item) => item.credentialId === credential.credentialId)
) {
throw new Error("WRN-AUTH-PASSKEY-EXISTS");
}
this.passkeys.set(credential.id, clone(credential));
}
async updatePasskey(credential: PasskeyCredential): Promise<void> {
const current = this.passkeys.get(credential.id);
if (!current) throw new Error("WRN-AUTH-PASSKEY-MISSING");
if (
credential.userId !== current.userId ||
credential.credentialId !== current.credentialId ||
credential.createdAt !== current.createdAt
) {
throw new Error("WRN-AUTH-PASSKEY-IMMUTABLE");
}
this.passkeys.set(credential.id, clone(credential));
}
async findPasskeyByCredentialId(credentialId: string): Promise<PasskeyCredential | undefined> {
const value = [...this.passkeys.values()].find((item) => item.credentialId === credentialId);
return value ? clone(value) : undefined;
}
async listPasskeys(userId: string): Promise<PasskeyCredential[]> {
return [...this.passkeys.values()].filter((item) => item.userId === userId).map(clone);
}
async deletePasskey(id: string): Promise<void> {
this.passkeys.delete(id);
}
async createOAuthAccount(account: OAuthAccount): Promise<void> {
if (
this.oauthAccounts.has(account.id) ||
[...this.oauthAccounts.values()].some(
(item) =>
item.provider === account.provider &&
item.providerAccountId === account.providerAccountId,
)
) {
throw new Error("WRN-AUTH-OAUTH-ACCOUNT-EXISTS");
}
this.oauthAccounts.set(account.id, clone(account));
}
async updateOAuthAccount(account: OAuthAccount): Promise<void> {
const current = this.oauthAccounts.get(account.id);
if (!current) throw new Error("WRN-AUTH-OAUTH-ACCOUNT-MISSING");
if (
account.userId !== current.userId ||
account.provider !== current.provider ||
account.providerAccountId !== current.providerAccountId ||
account.createdAt !== current.createdAt
) {
throw new Error("WRN-AUTH-OAUTH-ACCOUNT-IMMUTABLE");
}
this.oauthAccounts.set(account.id, clone(account));
}
async findOAuthAccount(
provider: string,
providerAccountId: string,
): Promise<OAuthAccount | undefined> {
const value = [...this.oauthAccounts.values()].find(
(item) => item.provider === provider && item.providerAccountId === providerAccountId,
);
return value ? clone(value) : undefined;
}
async listOAuthAccounts(userId: string): Promise<OAuthAccount[]> {
return [...this.oauthAccounts.values()].filter((item) => item.userId === userId).map(clone);
}
async deleteOAuthAccount(id: string): Promise<void> {
this.oauthAccounts.delete(id);
}
async createLoginAttempt(attempt: LoginAttempt): Promise<void> {
if (this.attempts.some((item) => item.id === attempt.id)) {
throw new Error("WRN-AUTH-LOGIN-ATTEMPT-EXISTS");
}
const identifier = attempt.identifier
? normalizeIdentity(inferIdentityType(attempt.identifier), attempt.identifier)
: undefined;
this.attempts.push(clone({ ...attempt, identifier }));
}
async listRecentLoginAttempts(identifier: string, since: number): Promise<LoginAttempt[]> {
const normalized = normalizeIdentity(inferIdentityType(identifier), identifier);
return this.attempts
.filter((item) => {
if (item.createdAt < since || !item.identifier) return false;
return (
normalizeIdentity(inferIdentityType(item.identifier), item.identifier) === normalized
);
})
.map(clone);
}
async createSecurityEvent(event: AuthSecurityEvent): Promise<void> {
if (this.events.some((item) => item.id === event.id)) {
throw new Error("WRN-AUTH-SECURITY-EVENT-EXISTS");
}
this.events.push(clone(event));
}
async listSecurityEvents(userId: string, limit = 100): Promise<AuthSecurityEvent[]> {
return this.events
.filter((item) => item.userId === userId)
.sort((left, right) => right.createdAt - left.createdAt)
.slice(0, limit)
.map(clone);
}
}
+663
View File
@@ -0,0 +1,663 @@
import type { Db, Row } from "@wrnexus/db";
import type { AuthStore } from "../store.ts";
import { inferIdentityType, normalizeIdentity } from "../normalize.ts";
import type {
AuthIdentity,
AuthSecurityEvent,
AuthSession,
AuthUser,
LoginAttempt,
OAuthAccount,
OneTimeToken,
OtpChallenge,
PasskeyCredential,
PasswordCredential,
RecoveryCodeRecord,
TotpCredential,
TrustedDevice,
} from "../types.ts";
function bool(value: unknown): boolean {
return value === true || value === 1 || value === "1";
}
function json<T>(value: unknown, fallback: T): T {
if (typeof value !== "string" || !value) return fallback;
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
function placeholders(sql: string, dialect: string): string {
if (dialect !== "postgres") return sql;
let index = 0;
return sql.replace(/\?/g, () => `$${++index}`);
}
export class SqlAuthStore implements AuthStore {
constructor(private readonly db: Db) {}
private sql(value: string): string {
return placeholders(value, this.db.driver.dialect);
}
private async one(sql: string, params: unknown[] = []): Promise<Row | undefined> {
return (await this.db.one(this.sql(sql), params)) ?? undefined;
}
private async all(sql: string, params: unknown[] = []): Promise<Row[]> {
return this.db.all(this.sql(sql), params);
}
private exec(sql: string, params: unknown[] = []) {
return this.db.exec(this.sql(sql), params);
}
async createUser(user: AuthUser): Promise<void> {
await this.exec(
"INSERT INTO wrn_auth_users (id,username,display_name,avatar_url,status,roles_json,email_verified,phone_verified,mfa_enabled,locale,timezone,created_at,updated_at,last_login_at,metadata_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
[
user.id,
user.username,
user.displayName,
user.avatarUrl,
user.status,
JSON.stringify(user.roles),
Number(user.emailVerified),
Number(user.phoneVerified),
Number(user.mfaEnabled),
user.locale,
user.timezone,
user.createdAt,
user.updatedAt,
user.lastLoginAt,
JSON.stringify(user.metadata ?? {}),
],
);
}
async updateUser(user: AuthUser): Promise<void> {
await this.exec(
"UPDATE wrn_auth_users SET username=?,display_name=?,avatar_url=?,status=?,roles_json=?,email_verified=?,phone_verified=?,mfa_enabled=?,locale=?,timezone=?,updated_at=?,last_login_at=?,metadata_json=? WHERE id=?",
[
user.username,
user.displayName,
user.avatarUrl,
user.status,
JSON.stringify(user.roles),
Number(user.emailVerified),
Number(user.phoneVerified),
Number(user.mfaEnabled),
user.locale,
user.timezone,
user.updatedAt,
user.lastLoginAt,
JSON.stringify(user.metadata ?? {}),
user.id,
],
);
}
private user(row: Row | undefined): AuthUser | undefined {
if (!row) return undefined;
return {
id: String(row.id),
username: row.username ? String(row.username) : undefined,
displayName: row.display_name ? String(row.display_name) : undefined,
avatarUrl: row.avatar_url ? String(row.avatar_url) : undefined,
status: String(row.status) as AuthUser["status"],
roles: json(row.roles_json, []),
emailVerified: bool(row.email_verified),
phoneVerified: bool(row.phone_verified),
mfaEnabled: bool(row.mfa_enabled),
locale: row.locale ? String(row.locale) : undefined,
timezone: row.timezone ? String(row.timezone) : undefined,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
lastLoginAt: row.last_login_at == null ? undefined : Number(row.last_login_at),
metadata: json(row.metadata_json, {}),
};
}
async findUserById(id: string): Promise<AuthUser | undefined> {
return this.user(await this.one("SELECT * FROM wrn_auth_users WHERE id=?", [id]));
}
async listUsers(): Promise<AuthUser[]> {
return (await this.all("SELECT * FROM wrn_auth_users ORDER BY created_at")).map((row) =>
this.user(row)!,
);
}
async createIdentity(x: AuthIdentity): Promise<void> {
await this.exec(
"INSERT INTO wrn_auth_identities (id,user_id,type,value,normalized_value,is_primary,verified_at,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
[
x.id,
x.userId,
x.type,
x.value,
x.normalizedValue,
Number(x.primary),
x.verifiedAt,
x.createdAt,
x.updatedAt,
],
);
}
async updateIdentity(x: AuthIdentity): Promise<void> {
await this.exec(
"UPDATE wrn_auth_identities SET value=?,normalized_value=?,is_primary=?,verified_at=?,updated_at=? WHERE id=?",
[x.value, x.normalizedValue, Number(x.primary), x.verifiedAt, x.updatedAt, x.id],
);
}
private identity(row: Row | undefined): AuthIdentity | undefined {
if (!row) return;
return {
id: String(row.id),
userId: String(row.user_id),
type: String(row.type) as AuthIdentity["type"],
value: String(row.value),
normalizedValue: String(row.normalized_value),
primary: bool(row.is_primary),
verifiedAt: row.verified_at == null ? undefined : Number(row.verified_at),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
}
async findIdentity(type: AuthIdentity["type"], value: string) {
return this.identity(
await this.one("SELECT * FROM wrn_auth_identities WHERE type=? AND normalized_value=?", [
type,
value,
]),
);
}
async listIdentities(userId: string) {
return (
await this.all(
"SELECT * FROM wrn_auth_identities WHERE user_id=? ORDER BY is_primary DESC, created_at",
[userId],
)
).map((r) => this.identity(r)!);
}
async setPassword(x: PasswordCredential) {
const current = await this.getPassword(x.userId);
if (current)
await this.exec(
"UPDATE wrn_auth_password_credentials SET password_hash=?,password_version=?,changed_at=?,must_change=? WHERE user_id=?",
[x.passwordHash, x.passwordVersion, x.changedAt, Number(x.mustChange), x.userId],
);
else
await this.exec(
"INSERT INTO wrn_auth_password_credentials (user_id,password_hash,password_version,changed_at,must_change) VALUES (?,?,?,?,?)",
[x.userId, x.passwordHash, x.passwordVersion, x.changedAt, Number(x.mustChange)],
);
}
async getPassword(userId: string) {
const r = await this.one("SELECT * FROM wrn_auth_password_credentials WHERE user_id=?", [
userId,
]);
return r
? {
userId: String(r.user_id),
passwordHash: String(r.password_hash),
passwordVersion: Number(r.password_version),
changedAt: Number(r.changed_at),
mustChange: bool(r.must_change),
}
: undefined;
}
async createSession(x: AuthSession) {
await this.exec(
"INSERT INTO wrn_auth_sessions (id,user_id,device_id,created_at,last_seen_at,expires_at,absolute_expires_at,ip,user_agent,trusted,revoked_at,revoke_reason,metadata_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
[
x.id,
x.userId,
x.deviceId,
x.createdAt,
x.lastSeenAt,
x.expiresAt,
x.absoluteExpiresAt,
x.ip,
x.userAgent,
Number(x.trusted),
x.revokedAt,
x.revokeReason,
JSON.stringify(x.metadata ?? {}),
],
);
}
async updateSession(x: AuthSession) {
await this.exec(
"UPDATE wrn_auth_sessions SET last_seen_at=?,expires_at=?,trusted=?,revoked_at=?,revoke_reason=?,metadata_json=? WHERE id=?",
[
x.lastSeenAt,
x.expiresAt,
Number(x.trusted),
x.revokedAt,
x.revokeReason,
JSON.stringify(x.metadata ?? {}),
x.id,
],
);
}
private session(r: Row | undefined): AuthSession | undefined {
if (!r) return;
return {
id: String(r.id),
userId: String(r.user_id),
deviceId: String(r.device_id),
createdAt: Number(r.created_at),
lastSeenAt: Number(r.last_seen_at),
expiresAt: Number(r.expires_at),
absoluteExpiresAt: Number(r.absolute_expires_at),
ip: r.ip ? String(r.ip) : undefined,
userAgent: r.user_agent ? String(r.user_agent) : undefined,
trusted: bool(r.trusted),
revokedAt: r.revoked_at == null ? undefined : Number(r.revoked_at),
revokeReason: r.revoke_reason ? String(r.revoke_reason) : undefined,
metadata: json(r.metadata_json, {}),
};
}
async findSession(id: string) {
return this.session(await this.one("SELECT * FROM wrn_auth_sessions WHERE id=?", [id]));
}
async listSessions(userId: string) {
return (
await this.all("SELECT * FROM wrn_auth_sessions WHERE user_id=? ORDER BY last_seen_at DESC", [
userId,
])
).map((r) => this.session(r)!);
}
async deleteSession(id: string) {
await this.exec("DELETE FROM wrn_auth_sessions WHERE id=?", [id]);
}
async createTrustedDevice(x: TrustedDevice) {
await this.exec(
"INSERT INTO wrn_auth_trusted_devices (id,user_id,name,fingerprint_hash,created_at,last_seen_at,expires_at,revoked_at) VALUES (?,?,?,?,?,?,?,?)",
[
x.id,
x.userId,
x.name,
x.fingerprintHash,
x.createdAt,
x.lastSeenAt,
x.expiresAt,
x.revokedAt,
],
);
}
async updateTrustedDevice(x: TrustedDevice) {
await this.exec(
"UPDATE wrn_auth_trusted_devices SET name=?,last_seen_at=?,expires_at=?,revoked_at=? WHERE id=?",
[x.name, x.lastSeenAt, x.expiresAt, x.revokedAt, x.id],
);
}
private device(r: Row | undefined): TrustedDevice | undefined {
if (!r) return;
return {
id: String(r.id),
userId: String(r.user_id),
name: String(r.name),
fingerprintHash: String(r.fingerprint_hash),
createdAt: Number(r.created_at),
lastSeenAt: Number(r.last_seen_at),
expiresAt: Number(r.expires_at),
revokedAt: r.revoked_at == null ? undefined : Number(r.revoked_at),
};
}
async findTrustedDeviceByFingerprint(userId: string, hash: string) {
return this.device(
await this.one(
"SELECT * FROM wrn_auth_trusted_devices WHERE user_id=? AND fingerprint_hash=?",
[userId, hash],
),
);
}
async listTrustedDevices(userId: string) {
return (
await this.all(
"SELECT * FROM wrn_auth_trusted_devices WHERE user_id=? ORDER BY last_seen_at DESC",
[userId],
)
).map((r) => this.device(r)!);
}
async createToken(x: OneTimeToken) {
await this.exec(
"INSERT INTO wrn_auth_tokens (id,user_id,purpose,token_hash,target,created_at,expires_at,used_at,attempts,max_attempts,metadata_json) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
[
x.id,
x.userId,
x.purpose,
x.tokenHash,
x.target,
x.createdAt,
x.expiresAt,
x.usedAt,
x.attempts,
x.maxAttempts,
JSON.stringify(x.metadata ?? {}),
],
);
}
async updateToken(x: OneTimeToken) {
await this.exec("UPDATE wrn_auth_tokens SET used_at=?,attempts=?,metadata_json=? WHERE id=?", [
x.usedAt,
x.attempts,
JSON.stringify(x.metadata ?? {}),
x.id,
]);
}
private token(r: Row | undefined): OneTimeToken | undefined {
if (!r) return;
return {
id: String(r.id),
userId: String(r.user_id),
purpose: String(r.purpose) as OneTimeToken["purpose"],
tokenHash: String(r.token_hash),
target: r.target ? String(r.target) : undefined,
createdAt: Number(r.created_at),
expiresAt: Number(r.expires_at),
usedAt: r.used_at == null ? undefined : Number(r.used_at),
attempts: Number(r.attempts),
maxAttempts: Number(r.max_attempts),
metadata: json(r.metadata_json, {}),
};
}
async findTokenByHash(hash: string) {
return this.token(await this.one("SELECT * FROM wrn_auth_tokens WHERE token_hash=?", [hash]));
}
async createOtp(x: OtpChallenge) {
await this.exec(
"INSERT INTO wrn_auth_otp_challenges (id,user_id,method,purpose,destination,code_hash,created_at,expires_at,used_at,attempts,max_attempts) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
[
x.id,
x.userId,
x.method,
x.purpose,
x.destination,
x.codeHash,
x.createdAt,
x.expiresAt,
x.usedAt,
x.attempts,
x.maxAttempts,
],
);
}
async updateOtp(x: OtpChallenge) {
await this.exec("UPDATE wrn_auth_otp_challenges SET used_at=?,attempts=? WHERE id=?", [
x.usedAt,
x.attempts,
x.id,
]);
}
async findOtp(id: string) {
const r = await this.one("SELECT * FROM wrn_auth_otp_challenges WHERE id=?", [id]);
return r
? {
id: String(r.id),
userId: String(r.user_id),
method: String(r.method) as OtpChallenge["method"],
purpose: String(r.purpose ?? "verification") as OtpChallenge["purpose"],
destination: String(r.destination),
codeHash: String(r.code_hash),
createdAt: Number(r.created_at),
expiresAt: Number(r.expires_at),
usedAt: r.used_at == null ? undefined : Number(r.used_at),
attempts: Number(r.attempts),
maxAttempts: Number(r.max_attempts),
}
: undefined;
}
async createTotp(x: TotpCredential) {
await this.exec(
"INSERT INTO wrn_auth_totp_credentials (id,user_id,label,secret,created_at,verified_at,last_counter) VALUES (?,?,?,?,?,?,?)",
[x.id, x.userId, x.label, x.secret, x.createdAt, x.verifiedAt, x.lastCounter],
);
}
async updateTotp(x: TotpCredential) {
await this.exec(
"UPDATE wrn_auth_totp_credentials SET label=?,verified_at=?,last_counter=? WHERE id=?",
[x.label, x.verifiedAt, x.lastCounter, x.id],
);
}
private totpRow(r: Row): TotpCredential {
return {
id: String(r.id),
userId: String(r.user_id),
label: String(r.label),
secret: String(r.secret),
createdAt: Number(r.created_at),
verifiedAt: r.verified_at == null ? undefined : Number(r.verified_at),
lastCounter: r.last_counter == null ? undefined : Number(r.last_counter),
};
}
async listTotp(userId: string) {
return (
await this.all("SELECT * FROM wrn_auth_totp_credentials WHERE user_id=?", [userId])
).map((r) => this.totpRow(r));
}
async deleteTotp(id: string) {
await this.exec("DELETE FROM wrn_auth_totp_credentials WHERE id=?", [id]);
}
async createRecoveryCodes(codes: RecoveryCodeRecord[]) {
for (const x of codes)
await this.exec(
"INSERT INTO wrn_auth_recovery_codes (id,user_id,code_hash,created_at,used_at) VALUES (?,?,?,?,?)",
[x.id, x.userId, x.codeHash, x.createdAt, x.usedAt],
);
}
async updateRecoveryCode(x: RecoveryCodeRecord) {
await this.exec("UPDATE wrn_auth_recovery_codes SET used_at=? WHERE id=?", [x.usedAt, x.id]);
}
async listRecoveryCodes(userId: string) {
return (await this.all("SELECT * FROM wrn_auth_recovery_codes WHERE user_id=?", [userId])).map(
(r) => ({
id: String(r.id),
userId: String(r.user_id),
codeHash: String(r.code_hash),
createdAt: Number(r.created_at),
usedAt: r.used_at == null ? undefined : Number(r.used_at),
}),
);
}
async deleteRecoveryCodes(userId: string) {
await this.exec("DELETE FROM wrn_auth_recovery_codes WHERE user_id=?", [userId]);
}
async createPasskey(x: PasskeyCredential) {
await this.exec(
"INSERT INTO wrn_auth_passkeys (id,user_id,credential_id,public_key,counter,transports_json,name,created_at,last_used_at,backed_up,device_type) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
[
x.id,
x.userId,
x.credentialId,
x.publicKey,
x.counter,
JSON.stringify(x.transports),
x.name,
x.createdAt,
x.lastUsedAt,
x.backedUp == null ? undefined : Number(x.backedUp),
x.deviceType,
],
);
}
async updatePasskey(x: PasskeyCredential) {
await this.exec(
"UPDATE wrn_auth_passkeys SET counter=?,transports_json=?,name=?,last_used_at=?,backed_up=?,device_type=? WHERE id=?",
[
x.counter,
JSON.stringify(x.transports),
x.name,
x.lastUsedAt,
x.backedUp == null ? undefined : Number(x.backedUp),
x.deviceType,
x.id,
],
);
}
private passkey(r: Row | undefined): PasskeyCredential | undefined {
if (!r) return;
return {
id: String(r.id),
userId: String(r.user_id),
credentialId: String(r.credential_id),
publicKey: String(r.public_key),
counter: Number(r.counter),
transports: json(r.transports_json, []),
name: String(r.name),
createdAt: Number(r.created_at),
lastUsedAt: r.last_used_at == null ? undefined : Number(r.last_used_at),
backedUp: r.backed_up == null ? undefined : bool(r.backed_up),
deviceType: r.device_type ? String(r.device_type) : undefined,
};
}
async findPasskeyByCredentialId(id: string) {
return this.passkey(
await this.one("SELECT * FROM wrn_auth_passkeys WHERE credential_id=?", [id]),
);
}
async listPasskeys(userId: string) {
return (await this.all("SELECT * FROM wrn_auth_passkeys WHERE user_id=?", [userId])).map((r) =>
this.passkey(r)!,
);
}
async deletePasskey(id: string) {
await this.exec("DELETE FROM wrn_auth_passkeys WHERE id=?", [id]);
}
async createOAuthAccount(x: OAuthAccount) {
await this.exec(
"INSERT INTO wrn_auth_oauth_accounts (id,user_id,provider,provider_account_id,email,access_token,refresh_token,token_expires_at,scope,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
[
x.id,
x.userId,
x.provider,
x.providerAccountId,
x.email,
x.accessToken,
x.refreshToken,
x.tokenExpiresAt,
x.scope,
x.createdAt,
x.updatedAt,
],
);
}
async updateOAuthAccount(x: OAuthAccount) {
await this.exec(
"UPDATE wrn_auth_oauth_accounts SET email=?,access_token=?,refresh_token=?,token_expires_at=?,scope=?,updated_at=? WHERE id=?",
[x.email, x.accessToken, x.refreshToken, x.tokenExpiresAt, x.scope, x.updatedAt, x.id],
);
}
private oauth(r: Row | undefined): OAuthAccount | undefined {
if (!r) return;
return {
id: String(r.id),
userId: String(r.user_id),
provider: String(r.provider),
providerAccountId: String(r.provider_account_id),
email: r.email ? String(r.email) : undefined,
accessToken: r.access_token ? String(r.access_token) : undefined,
refreshToken: r.refresh_token ? String(r.refresh_token) : undefined,
tokenExpiresAt: r.token_expires_at == null ? undefined : Number(r.token_expires_at),
scope: r.scope ? String(r.scope) : undefined,
createdAt: Number(r.created_at),
updatedAt: Number(r.updated_at),
};
}
async findOAuthAccount(provider: string, id: string) {
return this.oauth(
await this.one(
"SELECT * FROM wrn_auth_oauth_accounts WHERE provider=? AND provider_account_id=?",
[provider, id],
),
);
}
async listOAuthAccounts(userId: string) {
return (await this.all("SELECT * FROM wrn_auth_oauth_accounts WHERE user_id=?", [userId])).map(
(r) => this.oauth(r)!,
);
}
async deleteOAuthAccount(id: string) {
await this.exec("DELETE FROM wrn_auth_oauth_accounts WHERE id=?", [id]);
}
async createLoginAttempt(x: LoginAttempt) {
const identifier = x.identifier
? normalizeIdentity(inferIdentityType(x.identifier), x.identifier)
: undefined;
await this.exec(
"INSERT INTO wrn_auth_login_attempts (id,identifier,user_id,success,reason,ip,user_agent,created_at,risk_score,risk_level) VALUES (?,?,?,?,?,?,?,?,?,?)",
[
x.id,
identifier,
x.userId,
Number(x.success),
x.reason,
x.ip,
x.userAgent,
x.createdAt,
x.riskScore,
x.riskLevel,
],
);
}
async listRecentLoginAttempts(identifier: string, since: number) {
const normalized = normalizeIdentity(inferIdentityType(identifier), identifier);
const rows = await this.all(
"SELECT * FROM wrn_auth_login_attempts WHERE identifier=? AND created_at>=? ORDER BY created_at DESC",
[normalized, since],
);
return rows.map((r) => ({
id: String(r.id),
identifier: r.identifier ? String(r.identifier) : undefined,
userId: r.user_id ? String(r.user_id) : undefined,
success: bool(r.success),
reason: r.reason ? String(r.reason) : undefined,
ip: r.ip ? String(r.ip) : undefined,
userAgent: r.user_agent ? String(r.user_agent) : undefined,
createdAt: Number(r.created_at),
riskScore: Number(r.risk_score),
riskLevel: String(r.risk_level) as LoginAttempt["riskLevel"],
}));
}
async createSecurityEvent(x: AuthSecurityEvent) {
await this.exec(
"INSERT INTO wrn_auth_security_events (id,user_id,type,severity,actor_user_id,session_id,ip,user_agent,created_at,data_json) VALUES (?,?,?,?,?,?,?,?,?,?)",
[
x.id,
x.userId,
x.type,
x.severity,
x.actorUserId,
x.sessionId,
x.ip,
x.userAgent,
x.createdAt,
JSON.stringify(x.data ?? {}),
],
);
}
async listSecurityEvents(userId: string, limit = 100) {
const rows = await this.all(
"SELECT * FROM wrn_auth_security_events WHERE user_id=? ORDER BY created_at DESC LIMIT ?",
[userId, limit],
);
return rows.map((r) => ({
id: String(r.id),
userId: r.user_id ? String(r.user_id) : undefined,
type: String(r.type),
severity: String(r.severity) as AuthSecurityEvent["severity"],
actorUserId: r.actor_user_id ? String(r.actor_user_id) : undefined,
sessionId: r.session_id ? String(r.session_id) : undefined,
ip: r.ip ? String(r.ip) : undefined,
userAgent: r.user_agent ? String(r.user_agent) : undefined,
createdAt: Number(r.created_at),
data: json(r.data_json, {}),
}));
}
}
+186
View File
@@ -0,0 +1,186 @@
import { constantTimeEqual } from "../crypto.ts";
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const MIN_DIGITS = 6;
const MAX_DIGITS = 10;
const MAX_WINDOW = 20;
function assertPeriod(period: number): number {
if (!Number.isInteger(period) || period <= 0 || period > 86_400) {
throw new RangeError("TOTP period must be an integer between 1 and 86400 seconds");
}
return period;
}
function assertDigits(digits: number): number {
if (!Number.isInteger(digits) || digits < MIN_DIGITS || digits > MAX_DIGITS) {
throw new RangeError(`TOTP digits must be an integer between ${MIN_DIGITS} and ${MAX_DIGITS}`);
}
return digits;
}
function assertTimestamp(timestamp: number): number {
if (!Number.isFinite(timestamp) || timestamp < 0) {
throw new RangeError("TOTP timestamp must be a finite non-negative number");
}
return timestamp;
}
function assertWindow(window: number): number {
if (!Number.isInteger(window) || window < 0 || window > MAX_WINDOW) {
throw new RangeError(`TOTP window must be an integer between 0 and ${MAX_WINDOW}`);
}
return window;
}
export function encodeBase32(bytes: Uint8Array): string {
let bits = 0;
let value = 0;
let output = "";
for (const byte of bytes) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) output += ALPHABET[(value << (5 - bits)) & 31];
return output;
}
export function decodeBase32(value: string): Uint8Array {
const compact = value.toUpperCase().replace(/[\s-]/g, "");
if (!compact || !/^[A-Z2-7]+={0,6}$/.test(compact)) {
throw new TypeError("Invalid base32 secret");
}
const firstPadding = compact.indexOf("=");
const normalized = firstPadding < 0 ? compact : compact.slice(0, firstPadding);
if (!normalized) throw new TypeError("Invalid base32 secret");
let bits = 0;
let buffer = 0;
const output: number[] = [];
for (const character of normalized) {
const index = ALPHABET.indexOf(character);
if (index < 0) throw new TypeError("Invalid base32 secret");
buffer = (buffer << 5) | index;
bits += 5;
if (bits >= 8) {
output.push((buffer >>> (bits - 8)) & 255);
bits -= 8;
}
}
if (!output.length) throw new TypeError("Invalid base32 secret");
return new Uint8Array(output);
}
function counterBytes(counter: number): Uint8Array {
if (!Number.isSafeInteger(counter) || counter < 0) {
throw new RangeError("HOTP counter must be a non-negative safe integer");
}
const bytes = new Uint8Array(8);
let value = BigInt(counter);
for (let index = 7; index >= 0; index -= 1) {
bytes[index] = Number(value & 255n);
value >>= 8n;
}
return bytes;
}
async function hotp(secret: string, counter: number, digits = 6): Promise<string> {
const normalizedDigits = assertDigits(digits);
const key = await crypto.subtle.importKey(
"raw",
decodeBase32(secret) as BufferSource,
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"],
);
const digest = new Uint8Array(
await crypto.subtle.sign("HMAC", key, counterBytes(counter) as BufferSource),
);
const offset = digest[digest.length - 1] & 15;
const binary =
((digest[offset] & 127) << 24) |
((digest[offset + 1] & 255) << 16) |
((digest[offset + 2] & 255) << 8) |
(digest[offset + 3] & 255);
return String(binary % 10 ** normalizedDigits).padStart(normalizedDigits, "0");
}
export interface TotpOptions {
period?: number;
digits?: number;
timestamp?: number;
}
export function generateTotpSecret(randomBytes?: (length: number) => Uint8Array): string {
const bytes = randomBytes ? randomBytes(20) : crypto.getRandomValues(new Uint8Array(20));
if (!(bytes instanceof Uint8Array) || bytes.length !== 20) {
throw new TypeError("TOTP random byte provider must return exactly 20 bytes");
}
return encodeBase32(bytes);
}
export async function generateTotp(secret: string, options: TotpOptions = {}): Promise<string> {
const period = assertPeriod(options.period ?? 30);
const timestamp = assertTimestamp(options.timestamp ?? Date.now());
const digits = assertDigits(options.digits ?? 6);
return hotp(secret, Math.floor(timestamp / 1000 / period), digits);
}
export async function verifyTotp(
secret: string,
token: string,
options: TotpOptions & { window?: number; lastCounter?: number } = {},
): Promise<{ valid: boolean; counter?: number }> {
const period = assertPeriod(options.period ?? 30);
const timestamp = assertTimestamp(options.timestamp ?? Date.now());
const digits = assertDigits(options.digits ?? 6);
const window = assertWindow(options.window ?? 1);
const normalizedToken = token.replace(/\s/g, "");
if (!new RegExp(`^\\d{${digits}}$`).test(normalizedToken)) return { valid: false };
const counter = Math.floor(timestamp / 1000 / period);
const lastCounter = options.lastCounter ?? -1;
if (!Number.isSafeInteger(lastCounter) || lastCounter < -1) {
throw new RangeError("TOTP lastCounter must be a safe integer greater than or equal to -1");
}
for (let offset = -window; offset <= window; offset += 1) {
const candidateCounter = counter + offset;
if (candidateCounter < 0 || candidateCounter <= lastCounter) continue;
const candidate = await hotp(secret, candidateCounter, digits);
if (await constantTimeEqual(candidate, normalizedToken)) {
return { valid: true, counter: candidateCounter };
}
}
return { valid: false };
}
export function totpUri(input: {
issuer: string;
accountName: string;
secret: string;
period?: number;
digits?: number;
}): string {
const issuer = input.issuer.trim();
const accountName = input.accountName.trim();
if (!issuer || !accountName) {
throw new TypeError("TOTP issuer and account name are required");
}
decodeBase32(input.secret);
const period = assertPeriod(input.period ?? 30);
const digits = assertDigits(input.digits ?? 6);
const label = encodeURIComponent(`${issuer}:${accountName}`);
const params = new URLSearchParams({
secret: input.secret.toUpperCase().replace(/[\s-]/g, "").replace(/=+$/g, ""),
issuer,
period: String(period),
digits: String(digits),
algorithm: "SHA1",
});
return `otpauth://totp/${label}?${params.toString()}`;
}
+461
View File
@@ -0,0 +1,461 @@
import type { Context } from "@wrnexus/core";
export type AuthIdentityType = "email" | "phone" | "username";
export type AuthTokenPurpose =
| "verify-email"
| "verify-phone"
| "password-reset"
| "magic-link"
| "invite"
| "change-email"
| "change-phone"
| "login-mfa";
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";
export interface AuthUser {
id: string;
username?: string;
displayName?: string;
avatarUrl?: string;
status: AuthAccountStatus;
roles: string[];
emailVerified: boolean;
phoneVerified: boolean;
mfaEnabled: boolean;
locale?: string;
timezone?: string;
createdAt: number;
updatedAt: number;
lastLoginAt?: number;
metadata?: Record<string, unknown>;
}
export interface AuthIdentity {
id: string;
userId: string;
type: AuthIdentityType;
value: string;
normalizedValue: string;
primary: boolean;
verifiedAt?: number;
createdAt: number;
updatedAt: number;
}
export interface PasswordCredential {
userId: string;
passwordHash: string;
passwordVersion: number;
changedAt: number;
mustChange: boolean;
}
export interface AuthSession {
id: string;
userId: string;
deviceId: string;
createdAt: number;
lastSeenAt: number;
expiresAt: number;
absoluteExpiresAt: number;
ip?: string;
userAgent?: string;
trusted: boolean;
revokedAt?: number;
revokeReason?: string;
metadata?: Record<string, unknown>;
}
export interface TrustedDevice {
id: string;
userId: string;
name: string;
fingerprintHash: string;
createdAt: number;
lastSeenAt: number;
expiresAt: number;
revokedAt?: number;
}
export interface OneTimeToken {
id: string;
userId: string;
purpose: AuthTokenPurpose;
tokenHash: string;
target?: string;
createdAt: number;
expiresAt: number;
usedAt?: number;
attempts: number;
maxAttempts: number;
metadata?: Record<string, unknown>;
}
export interface OtpChallenge {
id: string;
userId: string;
method: "email-otp" | "sms-otp";
purpose: "verification" | "login" | "mfa";
destination: string;
codeHash: string;
createdAt: number;
expiresAt: number;
usedAt?: number;
attempts: number;
maxAttempts: number;
}
export interface TotpCredential {
id: string;
userId: string;
label: string;
secret: string;
createdAt: number;
verifiedAt?: number;
lastCounter?: number;
}
export interface RecoveryCodeRecord {
id: string;
userId: string;
codeHash: string;
createdAt: number;
usedAt?: number;
}
export interface PasskeyCredential {
id: string;
userId: string;
credentialId: string;
publicKey: string;
counter: number;
transports: string[];
name: string;
createdAt: number;
lastUsedAt?: number;
backedUp?: boolean;
deviceType?: string;
}
export interface OAuthAccount {
id: string;
userId: string;
provider: string;
providerAccountId: string;
email?: string;
accessToken?: string;
refreshToken?: string;
tokenExpiresAt?: number;
scope?: string;
createdAt: number;
updatedAt: number;
}
export interface LoginAttempt {
id: string;
identifier?: string;
userId?: string;
success: boolean;
reason?: string;
ip?: string;
userAgent?: string;
createdAt: number;
riskScore: number;
riskLevel: AuthRiskLevel;
}
export interface AuthSecurityEvent {
id: string;
userId?: string;
type: string;
severity: "info" | "warning" | "critical";
actorUserId?: string;
sessionId?: string;
ip?: string;
userAgent?: string;
createdAt: number;
data?: Record<string, unknown>;
}
export interface AuthRiskSignals {
failedAttempts?: number;
unfamiliarDevice?: boolean;
unusualIp?: boolean;
impossibleTravel?: boolean;
breachedPassword?: boolean;
automationSuspected?: boolean;
accountLocked?: boolean;
customScore?: number;
}
export interface AuthRiskDecision {
score: number;
level: AuthRiskLevel;
requireCaptcha: boolean;
requireMfa: boolean;
block: boolean;
reasons: string[];
}
export interface AuthPublicUser {
id: string;
username?: string;
displayName?: string;
avatarUrl?: string;
status: AuthAccountStatus;
roles: string[];
emailVerified: boolean;
phoneVerified: boolean;
mfaEnabled: boolean;
}
export interface AuthDeliveryMessage {
channel: "email" | "sms";
template:
| "verify-email"
| "verify-phone"
| "password-reset"
| "magic-link"
| "email-otp"
| "sms-otp"
| "login-alert"
| "invitation";
destination: string;
code?: string;
token?: string;
url?: string;
user: AuthPublicUser;
expiresAt: number;
data?: Record<string, unknown>;
}
export interface AuthDeliveryProvider {
send(message: AuthDeliveryMessage): Promise<void>;
}
/** HTTP response hook invoked after the auth engine establishes a signed-in session. */
export type AuthSignedInHandler = (ctx: Context, returnTo?: string) => Response | Promise<Response>;
/** HTTP response hook invoked after the auth engine clears a signed-in session. */
export type AuthSignedOutHandler = (ctx: Context) => Response | Promise<Response>;
export interface AuthSuccessfulSignUpAction {
/**
* Run the normal login policy with the newly registered credentials and
* establish a session when verification, CAPTCHA, MFA, and account policy
* allow it.
*/
autoSignIn?: boolean;
/** Same-origin path used after signup. Defaults to /account for auto sign-in and /sign-in otherwise. */
redirectTo?: string;
}
export type AuthSuccessfulSignUpHandler = (
ctx: Context,
user: AuthPublicUser,
) =>
| AuthSuccessfulSignUpAction
| Response
| void
| Promise<AuthSuccessfulSignUpAction | Response | void>;
/** Input used to build links placed in verification, recovery, magic-link, and invitation messages. */
export interface AuthTokenUrlInput {
purpose: AuthTokenPurpose;
token: string;
baseUrl?: string;
destination: string;
user: AuthPublicUser;
}
/** Encrypts sensitive authentication material before persistence. */
export interface AuthSecretProtector {
protect(value: string, purpose: "totp" | "oauth-access" | "oauth-refresh"): Promise<string>;
reveal(value: string, purpose: "totp" | "oauth-access" | "oauth-refresh"): Promise<string>;
}
export interface AuthImpersonationDecision {
allowed: boolean;
reason?: string;
}
export interface PasswordBreachProvider {
isBreached(password: string): Promise<boolean>;
}
export interface PasskeyRegistrationOptions {
challenge: string;
rp: { id: string; name: string };
user: { id: string; name: string; displayName: string };
timeout: number;
attestation: "none" | "direct" | "enterprise";
authenticatorSelection?: Record<string, unknown>;
excludeCredentials?: Array<{ id: string; type: "public-key"; transports?: string[] }>;
}
export interface PasskeyAuthenticationOptions {
challenge: string;
rpId: string;
timeout: number;
userVerification: "required" | "preferred" | "discouraged";
allowCredentials?: Array<{ id: string; type: "public-key"; transports?: string[] }>;
}
export interface PasskeyVerificationResult {
verified: boolean;
credential?: Omit<PasskeyCredential, "id" | "userId" | "createdAt">;
credentialId?: string;
newCounter?: number;
userId?: string;
error?: string;
}
export interface PasskeyProvider {
registrationOptions(input: {
user: AuthUser;
identities: AuthIdentity[];
credentials: PasskeyCredential[];
rpId: string;
rpName: string;
origin: string;
}): Promise<PasskeyRegistrationOptions>;
verifyRegistration(input: {
user: AuthUser;
response: unknown;
expectedChallenge: string;
expectedOrigin: string;
expectedRpId: string;
}): Promise<PasskeyVerificationResult>;
authenticationOptions(input: {
user?: AuthUser;
credentials: PasskeyCredential[];
rpId: string;
origin: string;
}): Promise<PasskeyAuthenticationOptions>;
verifyAuthentication(input: {
response: unknown;
credential?: PasskeyCredential;
expectedChallenge: string;
expectedOrigin: string;
expectedRpId: string;
}): Promise<PasskeyVerificationResult>;
}
export interface AuthClock {
now(): number;
}
export interface AuthRandom {
bytes(length: number): Uint8Array;
}
export interface AuthEngineOptions {
store: import("./store.ts").AuthStore;
secret: string;
issuer?: string;
delivery?: AuthDeliveryProvider;
/**
* Customize the HTTP response after any package sign-in flow succeeds.
* Keeping this beside delivery and tokenUrl makes the engine the single
* location for authentication behavior.
*/
onSignedIn?: AuthSignedInHandler;
/** Customize the HTTP response after a package logout succeeds. */
onSignedOut?: AuthSignedOutHandler;
/**
* Customize successful package registration. Without this hook, signup
* redirects to /sign-in.
*/
onSuccessfulSignUp?: AuthSuccessfulSignUpHandler;
/** @deprecated Misspelled alias; use onSuccessfulSignUp. */
onSuccessfullSignUp?: AuthSuccessfulSignUpHandler;
breachProvider?: PasswordBreachProvider;
passkeys?: PasskeyProvider;
passkeyChallengeStore?: import("./passkeys/index.ts").PasskeyChallengeStore;
clock?: AuthClock;
random?: AuthRandom;
sessionTtlMs?: number;
sessionAbsoluteTtlMs?: number;
trustedDeviceTtlMs?: number;
tokenTtlMs?: Partial<Record<AuthTokenPurpose, number>>;
/**
* Customize action links delivered with one-time tokens. Returning undefined
* intentionally omits the URL while still delivering the raw token.
*/
tokenUrl?: (input: AuthTokenUrlInput) => string | undefined;
otpTtlMs?: number;
maxTokenAttempts?: number;
maxOtpAttempts?: number;
maxFailedLogins?: number;
lockDurationMs?: number;
passwordMinLength?: number;
requireVerifiedEmail?: boolean;
requireVerifiedPhone?: boolean;
captchaThreshold?: number;
mfaThreshold?: number;
blockThreshold?: number;
skipMfaForTrustedDevices?: boolean;
sendLoginAlerts?: boolean;
secretProtector?: AuthSecretProtector;
linkVerifiedOAuthEmails?: boolean;
isOAuthEmailVerified?: (
provider: string,
profile: import("@wrnexus/oauth").OAuthProfile,
) => boolean | Promise<boolean>;
authorizeImpersonation?: (input: {
actor: AuthUser;
target: AuthUser;
reason?: string;
}) => boolean | AuthImpersonationDecision | Promise<boolean | AuthImpersonationDecision>;
audit?: (event: AuthSecurityEvent) => void | Promise<void>;
}
export interface RegisterInput {
email?: string;
phone?: string;
username?: string;
password: string;
displayName?: string;
locale?: string;
timezone?: string;
metadata?: Record<string, unknown>;
}
export interface LoginInput {
identifier: string;
password: string;
ip?: string;
userAgent?: string;
deviceId?: string;
deviceName?: string;
fingerprint?: string;
rememberDevice?: boolean;
captchaVerified?: boolean;
signals?: AuthRiskSignals;
}
export interface AuthResult {
ok: boolean;
code?: string;
message?: string;
user?: AuthPublicUser;
session?: AuthSession;
risk?: AuthRiskDecision;
mfaToken?: string;
requires?: {
captcha?: boolean;
mfa?: AuthMfaMethod[];
emailVerification?: boolean;
phoneVerification?: boolean;
};
}
export interface AuthenticatedContext extends Context {
user: AuthPublicUser;
locals: Context["locals"] & {
authUser: AuthPublicUser;
authSession?: AuthSession;
};
}
+355
View File
@@ -0,0 +1,355 @@
import { v, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation";
const strongPassword = () =>
v
.string()
.required("Enter your password")
.min(12, "Password must be at least 12 characters")
.max(256, "Password must be at most 256 characters")
.pattern(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/,
"Password must include uppercase, lowercase, and a number",
);
/** Generic server-side registration input for custom registration experiences. */
export const registerSchema = v.object({
displayName: v.string().trim().min(2, "Enter your full name").max(120),
email: v.string().trim().email("Enter a valid email address").optional(),
phone: v.string().trim().min(7, "Enter a valid phone number").max(24).optional(),
username: v
.string()
.trim()
.min(3, "Username must be at least 3 characters")
.max(64)
.pattern(/^[a-zA-Z0-9._-]+$/, "Use only letters, numbers, dots, underscores, or hyphens")
.optional(),
password: strongPassword(),
locale: v.string().max(32).optional(),
timezone: v.string().max(64).optional(),
});
/** Default schema shared by the packaged SignUp component and register route. */
export const signUpSchema = registerSchema.extend({
email: v
.string()
.trim()
.required("Enter your email address")
.email("Enter a valid email address"),
consent: v.boolean().required("Accept the terms and privacy policy to continue"),
});
export const loginSchema = v.object({
identifier: v.string().trim().required("Enter your email, phone, or username").max(320),
password: v.string().required("Enter your password").max(256),
returnTo: v.string().max(2048).optional(),
rememberDevice: v.boolean().optional(),
deviceFingerprint: v.string().max(512).optional(),
deviceName: v.string().max(120).optional(),
});
export const verificationRequestSchema = v.object({
type: v
.string()
.required("Choose email or phone verification")
.oneOf(["email", "phone"], "Choose email or phone verification"),
identifier: v.string().trim().max(320).optional(),
});
export const verificationTokenSchema = v.object({
token: v
.string()
.trim()
.required("Enter the verification token")
.min(6, "Verification token is too short")
.max(512),
});
export const passwordResetRequestSchema = v.object({
identifier: v
.string()
.trim()
.required("Enter your email, phone, or username")
.max(320, "Account identifier is too long"),
});
export const passwordResetSchema = v.object({
token: v
.string()
.required("Password reset token is missing")
.min(20, "Password reset token is invalid")
.max(512),
password: strongPassword(),
});
export const invitationAcceptSchema = v.object({
token: v
.string()
.required("Invitation token is missing")
.min(20, "Invitation token is invalid")
.max(512),
displayName: v.string().trim().min(2, "Enter your full name").max(120).optional(),
password: strongPassword().optional(),
});
export const magicLinkRequestSchema = v.object({
identifier: v.string().trim().required("Enter your email, phone, or username").max(320),
});
export const magicLinkConsumeSchema = v.object({
token: v
.string()
.required("Magic-link token is missing")
.min(20, "Magic-link token is invalid")
.max(512),
returnTo: v.string().max(2048).optional(),
});
export const otpLoginRequestSchema = v.object({
identifier: v.string().trim().required("Enter your email, phone, or username").max(320),
method: v
.string()
.required("Choose an OTP delivery method")
.oneOf(["email-otp", "sms-otp"], "Choose email or SMS OTP"),
});
export const otpLoginCompleteSchema = v.object({
challengeId: v
.string()
.required("OTP challenge is missing")
.min(8, "OTP challenge is invalid")
.max(191),
code: v
.string()
.trim()
.required("Enter the one-time code")
.pattern(/^\d{6}$/, "Enter the six-digit one-time code"),
returnTo: v.string().max(2048).optional(),
});
export const otpIssueSchema = v.object({
method: v
.string()
.required("Choose an OTP delivery method")
.oneOf(["email-otp", "sms-otp"], "Choose email or SMS OTP"),
destination: v.string().trim().max(320).optional(),
});
export const otpSchema = v.object({
challengeId: v
.string()
.required("OTP challenge is missing")
.min(8, "OTP challenge is invalid")
.max(191),
code: v
.string()
.trim()
.required("Enter the one-time code")
.pattern(/^\d{6}$/, "Enter the six-digit one-time code"),
});
export const mfaOtpRequestSchema = v.object({
mfaToken: v
.string()
.required("MFA transaction is missing")
.min(20, "MFA transaction is invalid")
.max(512),
method: v
.string()
.required("Choose an MFA delivery method")
.oneOf(["email-otp", "sms-otp"], "Choose email or SMS OTP"),
});
export const mfaSchema = v.object({
mfaToken: v
.string()
.required("MFA transaction is missing")
.min(20, "MFA transaction is invalid")
.max(512),
method: v
.string()
.required("Choose a verification method")
.oneOf(
["totp", "recovery-code", "email-otp", "sms-otp"],
"Choose a supported verification method",
),
challengeId: v.string().max(191).optional(),
code: v
.string()
.trim()
.required("Enter the verification code")
.min(6, "Verification code is too short")
.max(32),
returnTo: v.string().max(2048).optional(),
});
export const sessionRevokeSchema = v.object({
sessionId: v.string().required("Session ID is missing").min(3).max(191),
});
export const impersonationStartSchema = v.object({
targetUserId: v.string().required("Choose a user to impersonate").min(3).max(191),
reason: v.string().trim().max(500).optional(),
});
export const passkeyRegistrationOptionsSchema = v.object({
rpId: v.string().trim().max(253).optional(),
rpName: v.string().trim().max(120).optional(),
origin: v.string().trim().url("Enter a valid origin").optional(),
});
export const passkeyRegistrationVerifySchema = v.object({
key: v.string().required("Passkey challenge key is missing").min(8).max(512),
response: v.unknown().required("Passkey response is missing"),
rpId: v.string().trim().max(253).optional(),
origin: v.string().trim().url("Enter a valid origin").optional(),
name: v.string().trim().max(120).optional(),
});
export const passkeyAuthenticationOptionsSchema = v.object({
identifier: v.string().trim().max(320).optional(),
rpId: v.string().trim().max(253).optional(),
origin: v.string().trim().url("Enter a valid origin").optional(),
});
export const passkeyAuthenticationVerifySchema = v.object({
key: v.string().required("Passkey challenge key is missing").min(8).max(512),
response: v.unknown().required("Passkey response is missing"),
rpId: v.string().trim().max(253).optional(),
origin: v.string().trim().url("Enter a valid origin").optional(),
});
export const authenticatorSetupSchema = v.object({
label: v.string().trim().max(120).optional(),
});
export const authenticatorConfirmSchema = v.object({
credentialId: v.string().required("Authenticator credential is missing").min(3).max(191),
code: v
.string()
.trim()
.required("Enter the authenticator code")
.pattern(/^\d{6}$/, "Enter the six-digit authenticator code"),
});
export const authenticatorDisableSchema = v.object({
credentialId: v.string().required("Authenticator credential is missing").min(3).max(191),
});
export const recoveryCodesSchema = v.object({
count: v.number().integer("Recovery code count must be a whole number").min(1).max(50).optional(),
});
export const emptyActionSchema = v.object({});
export const changePasswordSchema = v.object({
currentPassword: v.string().required("Enter your current password").max(256),
nextPassword: strongPassword(),
});
export interface AuthSchemaSet {
register: ObjectSchema;
signUp: ObjectSchema;
login: ObjectSchema;
verificationRequest: ObjectSchema;
verificationToken: ObjectSchema;
passwordResetRequest: ObjectSchema;
passwordReset: ObjectSchema;
invitationAccept: ObjectSchema;
magicLinkRequest: ObjectSchema;
magicLinkConsume: ObjectSchema;
otpLoginRequest: ObjectSchema;
otpLoginComplete: ObjectSchema;
otpIssue: ObjectSchema;
otpVerify: ObjectSchema;
mfaOtpRequest: ObjectSchema;
mfaComplete: ObjectSchema;
sessionRevoke: ObjectSchema;
impersonationStart: ObjectSchema;
passkeyRegistrationOptions: ObjectSchema;
passkeyRegistrationVerify: ObjectSchema;
passkeyAuthenticationOptions: ObjectSchema;
passkeyAuthenticationVerify: ObjectSchema;
authenticatorSetup: ObjectSchema;
authenticatorConfirm: ObjectSchema;
authenticatorDisable: ObjectSchema;
recoveryCodes: ObjectSchema;
emptyAction: ObjectSchema;
changePassword: ObjectSchema;
}
export type AuthSchemaOverrides = Partial<AuthSchemaSet>;
export const authSchemas: AuthSchemaSet = {
register: signUpSchema,
signUp: signUpSchema,
login: loginSchema,
verificationRequest: verificationRequestSchema,
verificationToken: verificationTokenSchema,
passwordResetRequest: passwordResetRequestSchema,
passwordReset: passwordResetSchema,
invitationAccept: invitationAcceptSchema,
magicLinkRequest: magicLinkRequestSchema,
magicLinkConsume: magicLinkConsumeSchema,
otpLoginRequest: otpLoginRequestSchema,
otpLoginComplete: otpLoginCompleteSchema,
otpIssue: otpIssueSchema,
otpVerify: otpSchema,
mfaOtpRequest: mfaOtpRequestSchema,
mfaComplete: mfaSchema,
sessionRevoke: sessionRevokeSchema,
impersonationStart: impersonationStartSchema,
passkeyRegistrationOptions: passkeyRegistrationOptionsSchema,
passkeyRegistrationVerify: passkeyRegistrationVerifySchema,
passkeyAuthenticationOptions: passkeyAuthenticationOptionsSchema,
passkeyAuthenticationVerify: passkeyAuthenticationVerifySchema,
authenticatorSetup: authenticatorSetupSchema,
authenticatorConfirm: authenticatorConfirmSchema,
authenticatorDisable: authenticatorDisableSchema,
recoveryCodes: recoveryCodesSchema,
emptyAction: emptyActionSchema,
changePassword: changePasswordSchema,
};
export function resolveAuthSchemas(overrides: AuthSchemaOverrides = {}): AuthSchemaSet {
return { ...authSchemas, ...overrides };
}
export const authBrowserSchemaMap = {
"auth-register": "register",
"auth-login": "login",
"auth-verification-request": "verificationRequest",
"auth-verification-token": "verificationToken",
"auth-password-request": "passwordResetRequest",
"auth-password-reset": "passwordReset",
"auth-invitation": "invitationAccept",
"auth-magic-link-request": "magicLinkRequest",
"auth-magic-link-consume": "magicLinkConsume",
"auth-otp-login-request": "otpLoginRequest",
"auth-otp-login-complete": "otpLoginComplete",
"auth-otp-issue": "otpIssue",
"auth-otp": "otpVerify",
"auth-mfa-otp-request": "mfaOtpRequest",
"auth-mfa": "mfaComplete",
"auth-session-revoke": "sessionRevoke",
"auth-impersonation-start": "impersonationStart",
"auth-passkey-registration-options": "passkeyRegistrationOptions",
"auth-passkey-registration-verify": "passkeyRegistrationVerify",
"auth-passkey-authentication-options": "passkeyAuthenticationOptions",
"auth-passkey-authentication-verify": "passkeyAuthenticationVerify",
"auth-authenticator-setup": "authenticatorSetup",
"auth-authenticator-confirm": "authenticatorConfirm",
"auth-authenticator-disable": "authenticatorDisable",
"auth-recovery-codes": "recoveryCodes",
"auth-empty": "emptyAction",
"auth-change-password": "changePassword",
} as const satisfies Record<string, keyof AuthSchemaSet>;
export function authBrowserSchemaDescriptors(
schemas: AuthSchemaSet = authSchemas,
): Record<string, SchemaDescriptor> {
const descriptors: Record<string, SchemaDescriptor> = {};
for (const [browserName, schemaName] of Object.entries(authBrowserSchemaMap)) {
descriptors[browserName] = schemas[schemaName].describe();
}
return descriptors;
}