release: WRNexusJS 0.5.0
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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[];
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, {}),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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()}`;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user