release: WRNexusJS 0.5.0

This commit is contained in:
2026-07-29 12:51:10 +05:30
parent 76c768099d
commit 6afe32f63f
456 changed files with 40879 additions and 8850 deletions
+18
View File
@@ -0,0 +1,18 @@
# Changelog
## 0.5.0
- Added `AuthEngineOptions.tokenUrl` for custom verification, reset, magic-link, and invitation page URLs without application-owned API handlers.
- Added framework-native password, identity, verification, recovery, magic-link, OTP, OAuth, passkey, session, trusted-device, risk, audit, and impersonation systems.
- Added MFA transactions using verified email OTP, verified SMS OTP, replay-safe TOTP, and one-use recovery codes.
- Added route-specific modules for all 30 package endpoints, eliminating shared-dispatcher path-rewrite `404` failures.
- Added automatic package browser schemas and optional `config.auth.schemas` overrides; default applications no longer copy `app/schemas` or API route files.
- Added `config.auth` feature-group route controls, fail-closed automatic discovery, CSRF protection, middleware, component, client-runtime, migration, and DevToolbar controls.
- Added ordered OTP-purpose migration and purpose-bound verification, login, and MFA challenges.
- Added account-state checks for recovery and verification, password-reset lock clearing, session revocation, and generic enumeration-resistant request responses.
- Added login timing hardening, adaptive risk checks, safe same-origin navigation, canonical Base64URL validation, passkey counter and ownership checks, and controlled missing-provider responses.
- Added a shared passkey-challenge-store contract for multi-process deployments.
- Added TOTP counter replay prevention, recovery-code replacement semantics, secret-purpose binding, and delivery-failure auditing.
- Aligned the memory store with SQL uniqueness and immutable credential constraints.
- Expanded package tests for routes, schemas, CSRF, stores, lock recovery, account status, verified MFA identities, passkeys, crypto, TOTP, risk, and browser components.
+370
View File
@@ -0,0 +1,370 @@
# @wrnexus/auth
Framework-native authentication, identity, account-security, and session management for WRNexusJS.
## Capabilities
- Password registration, login, recovery, reset, and authenticated password changes
- Email, phone, and username identities with verification and generic resend responses
- Magic links and passwordless email/SMS OTP login
- MFA transactions using verified email OTP, verified SMS OTP, TOTP, or recovery codes
- RFC 6238 TOTP with counter replay protection
- One-use recovery codes; regeneration invalidates previous unused codes
- Passkey/WebAuthn registration and strong passwordless sign-in through a provider contract
- OAuth account linking and provider sign-in
- Invitations, session rotation, idle and absolute expiry, revocation, and trusted devices
- Deny-by-default audited support impersonation
- Adaptive risk scoring, CAPTCHA escalation, temporary lockout, and optional login alerts
- Memory and SQL stores
- Optional encryption-keyring protection for TOTP and OAuth secrets
- Automatic API routes, middleware, browser schemas, components, runtime, migrations, and DevToolbar checks
Passkeys are a strong sign-in method. They are not currently exposed as a selectable second step in `TwoFactorChallenge`; the implemented MFA methods are email OTP, SMS OTP, TOTP, and recovery codes.
## Install
```bash
bun add @wrnexus/auth
```
WRNexusJS discovers the package automatically. Do not copy package components, client scripts, schemas, or standard `/api/auth/*` route files into the application.
## Default configuration
Create the engine:
```ts
// app/lib/auth.ts
import { createAuthEngine, MemoryAuthStore } from "@wrnexus/auth";
export const auth = createAuthEngine({
store: new MemoryAuthStore(),
secret: process.env.AUTH_SECRET!,
issuer: "My application",
onSignedIn(ctx, returnTo) {
const safe = returnTo?.startsWith("/") && !returnTo.startsWith("//") ? returnTo : "/account";
return Response.redirect(new URL(safe, ctx.url), 303);
},
onSignedOut(ctx) {
return Response.redirect(new URL("/sign-in", ctx.url), 303);
},
onSuccessfulSignUp() {
return {
autoSignIn: true,
redirectTo: "/account",
};
},
delivery: {
async send(message) {
// Queue email/SMS through your provider. Never log message.code or message.token.
},
},
});
```
Authentication behavior belongs in this engine definition: delivery, token URL
mapping, successful sign-in/sign-out responses, password policy, risk thresholds,
MFA, passkeys, and auditing can all be configured in one server-only location.
The older `config.auth.onSignedIn` and `config.auth.onSignedOut` fields remain
supported as compatibility overrides, but new applications should configure
these hooks on `createAuthEngine`.
### Successful signup behavior
Without `onSuccessfulSignUp`, a successful package registration redirects to
`/sign-in`.
To sign in immediately after registration:
```ts
onSuccessfulSignUp(ctx, user) {
return {
autoSignIn: true,
redirectTo: "/account",
};
}
```
Automatic sign-in runs the normal login policy. It does not bypass required
email or phone verification, CAPTCHA, MFA, account status, or risk checks. The
hook may also return a `Response` for a completely custom HTTP result, or return
`{ redirectTo: "/welcome" }` to redirect without creating a session.
Register it through application configuration:
```ts
// wrnexus.config.ts
import type { AuthConfig } from "@wrnexus/auth";
import type { AppConfig } from "@wrnexus/styles";
import { auth } from "./app/lib/auth.ts";
const config = {
auth: {
engine: auth,
routes: true,
middleware: true,
migrations: false,
},
} satisfies AppConfig & { auth: AuthConfig };
export default config;
```
That configuration automatically activates package routes, auth-session middleware, components, browser validation schemas, and the auth client runtime. `setDefaultAuthEngine()` remains available only for advanced manual integrations and tests.
## SQL production configuration
```ts
import { createAuthEngine, SqlAuthStore } from "@wrnexus/auth";
import { getDb } from "@wrnexus/db";
export const auth = createAuthEngine({
store: new SqlAuthStore(getDb()),
secret: process.env.AUTH_SECRET!,
});
```
```ts
export default {
db: {
// Application database configuration.
},
auth: {
engine: auth,
routes: true,
middleware: true,
migrations: true,
},
};
```
The package contributes both ordered migrations:
```text
001_auth.sql
002_auth_otp_purpose.sql
```
Migrations are enabled automatically only when `auth.engine` and a default `config.db` are present. Set `auth.migrations` explicitly when an application needs different behavior.
## Delivered action URLs
By default, the engine builds links from the supplied `baseUrl` and token purpose. Applications can map those links to their own page structure without replacing package APIs:
```ts
const auth = createAuthEngine({
store: new SqlAuthStore(getDb()),
secret: process.env.AUTH_SECRET!,
tokenUrl({ purpose, token, baseUrl }) {
if (!baseUrl) return undefined;
const paths = {
"verify-email": `/verify-email?token=${encodeURIComponent(token)}`,
"verify-phone": `/verify-phone?token=${encodeURIComponent(token)}`,
"password-reset": `/recover/reset?token=${encodeURIComponent(token)}`,
"magic-link": `/magic-link?token=${encodeURIComponent(token)}`,
invite: `/invitation?token=${encodeURIComponent(token)}`,
};
const path = paths[purpose as keyof typeof paths];
return path ? new URL(path, baseUrl).toString() : undefined;
},
});
```
Returning `undefined` intentionally omits the URL while still delivering the raw token. The callback runs only in trusted server code.
## Built-in validation
Every packaged auth form has a built-in `@wrnexus/validation` schema. The same resolved schema is used by the browser and the package API handler.
Default use requires no `app/schemas` files:
```wrn
<SignUp />
<SignIn />
<ForgotPassword />
<ResetPassword token='{token}' />
<TwoFactorChallenge />
```
To customize one schema, extend the package default and register only that override:
```ts
// app/schemas/custom-password-request.ts
import { authSchemas } from "@wrnexus/auth";
import { v } from "@wrnexus/validation";
export default authSchemas.passwordResetRequest.extend({
identifier: v
.string()
.trim()
.required("Enter your registered email address")
.email("Enter a valid registered email address"),
});
```
```ts
import customPasswordRequest from "./app/schemas/custom-password-request.ts";
export default {
auth: {
engine: auth,
schemas: {
passwordResetRequest: customPasswordRequest,
},
},
};
```
`<ForgotPassword />` can keep its default `schema="auth-password-request"`. The plugin automatically publishes the overridden browser descriptor under that same built-in schema ID. All other forms continue using package defaults.
## Route controls
Use a boolean to enable or disable all package routes:
```ts
auth: {
engine: auth,
routes: true,
}
```
Or control feature groups:
```ts
routes: {
enabled: true,
registration: true,
login: true,
verification: true,
password: true,
invitations: true,
magicLink: true,
otp: true,
mfa: true,
sessions: true,
impersonation: false,
passkeys: true,
}
```
Application routes have normal framework precedence. Disable a package group only when the application intentionally owns every endpoint in that group; no `excludeRoutes` list is required.
## Package endpoints
```text
POST /api/auth/register
POST /api/auth/login
POST /api/auth/logout
POST /api/auth/verification/request
GET|POST /api/auth/verify/email
POST /api/auth/verify/phone
POST /api/auth/password/request
POST /api/auth/password/reset
POST /api/auth/password/change
POST /api/auth/invitations/accept
POST /api/auth/magic-link/request
GET|POST /api/auth/magic-link
POST /api/auth/otp/login/request
POST /api/auth/otp/login/complete
POST /api/auth/otp
POST /api/auth/otp/verify
POST /api/auth/totp/setup
POST /api/auth/totp/confirm
POST /api/auth/totp/disable
POST /api/auth/recovery-codes
POST /api/auth/mfa/otp
POST /api/auth/mfa/complete
GET /api/auth/sessions
POST /api/auth/sessions/revoke
POST /api/auth/impersonation/start
POST /api/auth/impersonation/stop
POST /api/auth/passkeys/register/options
POST /api/auth/passkeys/register/verify
POST /api/auth/passkeys/login/options
POST /api/auth/passkeys/login/verify
```
Each URL uses a route-specific module, so rewritten framework request URLs cannot make the handler fall through to a shared-dispatcher `404`.
Unsafe package routes validate the framework CSRF token by default. Set `auth.csrf: false` only when an external API gateway provides an equivalent protection model.
## Components
```wrn
<SignIn />
<SignUp />
<ForgotPassword />
<ResetPassword token='{token}' />
<OtpSignIn method="email-otp" />
<MagicLinkSignIn />
<PasskeyButton mode="authenticate" />
<TwoFactorChallenge mfaToken='{mfaToken}' challengeId='{challengeId}' />
<AuthenticatorSetup credentialId='{credentialId}' secret='{secret}' uri='{uri}' />
<RecoveryCodes codes='{codes}' />
<DeviceSessions sessions='{sessions}' currentSessionId='{currentSessionId}' />
<VerifyEmail token='{token}' identifier='{identifier}' />
<VerifyPhone token='{token}' identifier='{identifier}' />
<InvitationAccept token='{token}' />
<ImpersonationBanner targetName='{targetName}' />
<AccountStatus status='{account.status}' />
```
`identifier` is optional on verification components. Supply it when an unauthenticated verification page should support resending a token. The response remains generic whether the account exists or not.
## CAPTCHA and risk
The HTTP handlers never trust a browser `captchaVerified` field. CAPTCHA completion is accepted only from server-populated `ctx.locals.captcha.success` or `ctx.locals.captchaVerified === true`.
Rate limiting remains an application or gateway responsibility. Apply it to registration, login, reset, magic-link, OTP, verification, passkey, invitation, and impersonation endpoints.
## MFA
1. Password, OAuth, magic-link, or OTP login may return `code: "mfa-required"` with a short-lived `mfaToken`.
2. The response lists only methods actually available to that user.
3. Email/SMS MFA is offered only for verified linked identities.
4. `beginMfaOtp()` issues an MFA-bound OTP when needed.
5. `completeMfa()` consumes the one-time transaction and creates the session.
## Passkeys
The browser runtime coordinates `navigator.credentials.create()` and `navigator.credentials.get()`. A configured server-side `PasskeyProvider` must verify the challenge, RP ID, origin, signature, user presence or verification, counter, and credential ownership.
Multi-process deployments must provide a shared `PasskeyChallengeStore`; the default memory implementation is process-local. Missing passkey providers return a controlled `503` response rather than crashing the route.
## Protect long-lived secrets
```ts
import { createAuthSecretProtector } from "@wrnexus/auth";
import { createKeyring } from "@wrnexus/encryption";
const keyring = createKeyring([
{
id: "auth-2026-01",
secret: process.env.AUTH_ENCRYPTION_KEY!,
active: true,
},
]);
const auth = createAuthEngine({
store: new SqlAuthStore(getDb()),
secret: process.env.AUTH_SECRET!,
secretProtector: createAuthSecretProtector(keyring),
});
```
TOTP seeds and OAuth access/refresh tokens are protected before persistence. Keep old keys available during rotation.
## Custom HTTP integration
`createAuthHttpHandlers()` remains available for custom route paths or response behavior. Prefer package routes for standard flows; copied application API files are unnecessary.
## Development
```bash
bun run auth:dev
bun run validate:auth
```
Read [SECURITY.md](./SECURITY.md) before production deployment.
+71
View File
@@ -0,0 +1,71 @@
# Authentication security guidance
## Required production controls
1. Use a cryptographically random authentication HMAC secret of at least 32 characters.
2. Use `SqlAuthStore` or another durable shared `AuthStore`; `MemoryAuthStore` is for development and tests.
3. Protect TOTP seeds and OAuth tokens with `createAuthSecretProtector()` and a rotated `@wrnexus/encryption` keyring.
4. Queue verification delivery and never log OTP codes, raw one-time tokens, reset URLs, or provider secrets.
5. Rate-limit registration, login, verification, OTP, reset, magic-link, invitation, passkey, and impersonation endpoints.
6. Accept CAPTCHA success only from a server-side verifier such as `captchaGuard()`.
7. Keep package CSRF verification enabled for cookie-authenticated unsafe requests unless an equivalent gateway control replaces it.
8. Validate every post-authentication redirect as same-origin.
9. Use HTTPS, `Secure` and `HttpOnly` session cookies, an appropriate `SameSite` policy, strict security headers, and trusted-proxy configuration.
10. Persist security events in a durable, access-controlled audit sink.
11. Require a reason and explicit authorization policy for impersonation and show a persistent banner while it is active.
12. Require recent or step-up authentication before especially sensitive account-management actions when the surrounding product policy demands it.
## Passwords and lockout
Passwords use the framework password API. Keep the default 12-character minimum or raise it, and configure a breach provider in production. Browser validation is only a usability layer; server validation and engine policy remain authoritative.
Temporary failed-login locks preserve the previous account state. A valid password reset clears only a failed-login lock; it does not reactivate an administratively locked, disabled, or deleted account.
## Tokens and OTP
Reset, verification, invitation, magic-link, and MFA transaction tokens are HMAC-hashed at rest, expire, and are one-use. OTP challenges are bound to verification, login, or MFA purpose and have attempt limits. Email/SMS MFA can use only verified linked identities.
Durable custom stores should consume one-time credentials atomically, preferably in the same transaction as the protected state change. The generic `AuthStore` interface exposes read/update operations and cannot by itself guarantee compare-and-set behavior across concurrent processes.
Public reset, magic-link, passwordless-OTP, and verification-request endpoints return generic responses to reduce account enumeration. Rate limiting and delivery-abuse controls are still required.
For login forms, pair `captchaGuard({ verifiedForMs })` with
`<Captcha resetOnError="false" />` so a successful human check can survive an
incorrect-password retry. The grant remains session-bound and action-bound;
authentication rate limits, risk blocking, and account lockout remain required.
## Account recovery
Disabled, deleted, and administratively locked accounts cannot complete verification or password recovery with previously issued tokens. Password reset revokes active sessions. Recovery-code regeneration invalidates earlier unused codes.
## Sessions and trusted devices
The WRNexusJS session container is regenerated when an auth session is established. Auth sessions have idle and absolute expiry. Trusted-device values should be opaque, stable identifiers generated by the application—not high-entropy browser fingerprint profiles containing unnecessary personal data.
## TOTP and recovery codes
TOTP validation rejects reused counters. Encrypt TOTP seeds with `secretProtector`. Recovery codes are displayed once and stored only as hashes.
## Passkeys
The browser runtime does not perform cryptographic verification. The configured `PasskeyProvider` must verify the challenge, RP ID, exact origin, signature, user presence or verification, counter, and credential ownership. Require HTTPS outside localhost.
The default challenge store is memory-only. Use a shared Redis, SQL, or equivalent `PasskeyChallengeStore` when requests can reach more than one process. Passkeys are implemented as strong passwordless sign-in; they are not currently a selectable method in the second-step MFA challenge component.
## OAuth
Use PKCE, state, nonce, and callback validation from `@wrnexus/oauth`. Encrypt provider tokens before durable storage. Never auto-link an OAuth account solely from an unverified provider email.
## Database integrity
Run both package migrations in order. Production schemas should preserve the included unique constraints for normalized identities, passkey credential IDs, trusted-device fingerprints, token hashes, and OAuth provider account IDs.
Registration and one-time credential state changes should run in transactions when a custom store supports them. This prevents partial writes and concurrent replay beyond what a generic multi-operation store can guarantee.
## Impersonation
`authorizeImpersonation` defaults to deny. Restrict it to named roles, require a reason, preserve the actor session, display `ImpersonationBanner`, and notify the organization when policy requires it. Apply a separate rule before allowing an actor to impersonate a more privileged target.
## Delivered links
Use `AuthEngineOptions.tokenUrl` to map one-time credentials to application pages. Treat the callback as trusted server configuration, use HTTPS production origins, and do not log raw tokens or generated URLs.
+594
View File
@@ -0,0 +1,594 @@
/* global window, document, navigator, CustomEvent, HTMLElement, HTMLFormElement, MutationObserver, fetch, atob, btoa, Blob, URL, sessionStorage, localStorage, location, crypto */
(() => {
if (window.WRNexusAuth) return;
const mounted = new WeakSet();
const requestControllers = new WeakMap();
const cleanups = new WeakMap();
const recoveryMounted = new WeakSet();
const recoveryCleanups = new WeakMap();
const flowMounted = new WeakSet();
const flowCleanups = new WeakMap();
const MFA_STORAGE_KEY = "wrnexus.auth.mfa";
const DEVICE_STORAGE_KEY = "wrnexus.auth.device";
function fromBase64Url(value) {
const input = String(value);
if (!input || !/^[A-Za-z0-9_-]+$/.test(input) || input.length % 4 === 1) {
throw new TypeError("Passkey data must be canonical Base64URL without padding");
}
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(normalized + "=".repeat((4 - (normalized.length % 4)) % 4));
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
if (toBase64Url(bytes) !== input) {
throw new TypeError("Passkey data must be canonical Base64URL without padding");
}
return bytes;
}
function toBase64Url(value) {
const bytes =
value instanceof ArrayBuffer
? new Uint8Array(value)
: new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function publicKeyCreationOptions(options) {
return {
...options,
challenge: fromBase64Url(options.challenge),
user: { ...options.user, id: fromBase64Url(options.user.id) },
excludeCredentials: (options.excludeCredentials || []).map((item) => ({
...item,
id: fromBase64Url(item.id),
})),
};
}
function publicKeyRequestOptions(options) {
return {
...options,
challenge: fromBase64Url(options.challenge),
allowCredentials: (options.allowCredentials || []).map((item) => ({
...item,
id: fromBase64Url(item.id),
})),
};
}
function credentialToJson(credential) {
if (!credential) return null;
const response = credential.response;
const base = {
id: credential.id,
type: credential.type,
rawId: toBase64Url(credential.rawId),
authenticatorAttachment: credential.authenticatorAttachment,
clientExtensionResults: credential.getClientExtensionResults
? credential.getClientExtensionResults()
: {},
};
if (response && "attestationObject" in response) {
return {
...base,
response: {
clientDataJSON: toBase64Url(response.clientDataJSON),
attestationObject: toBase64Url(response.attestationObject),
transports: response.getTransports ? response.getTransports() : [],
},
};
}
return {
...base,
response: {
clientDataJSON: toBase64Url(response.clientDataJSON),
authenticatorData: toBase64Url(response.authenticatorData),
signature: toBase64Url(response.signature),
userHandle: response.userHandle ? toBase64Url(response.userHandle) : null,
},
};
}
function csrfHeaders() {
const match = document.cookie.match(/(?:^|;\s*)wire-csrf=([^;]+)/);
return match ? { "x-csrf-token": decodeURIComponent(match[1]) } : {};
}
async function json(url, body, signal) {
const response = await fetch(url, {
method: "POST",
credentials: "same-origin",
headers: {
"content-type": "application/json",
accept: "application/json",
...csrfHeaders(),
},
body: JSON.stringify(body),
signal,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.ok === false) {
const error = new Error(
payload.message || payload.error || `Request failed (${response.status})`,
);
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
}
function setState(element, state, message) {
element.dataset.authState = state;
element.setAttribute("aria-busy", state === "loading" ? "true" : "false");
const status = element.querySelector("[data-auth-status]");
if (status) {
status.textContent = message || "";
status.hidden = !message;
}
const button = element.matches("button") ? element : element.querySelector("button");
if (button) button.disabled = state === "loading";
}
function dispatch(element, name, detail) {
element.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
}
function navigate(target) {
if (!target) return false;
const url = new URL(target, location.href);
if (url.origin !== location.origin) return false;
if (window.__wrnexusNavigate) {
window.__wrnexusNavigate(url.pathname + url.search + url.hash);
} else {
location.assign(url.href);
}
return true;
}
function randomDeviceId() {
if (crypto.randomUUID) return crypto.randomUUID();
const bytes = crypto.getRandomValues(new Uint8Array(24));
return toBase64Url(bytes);
}
function deviceFingerprint() {
try {
let value = localStorage.getItem(DEVICE_STORAGE_KEY);
if (!value) {
value = randomDeviceId();
localStorage.setItem(DEVICE_STORAGE_KEY, value);
}
return value;
} catch {
return randomDeviceId();
}
}
function toggle(element, visible) {
if (!element) return;
element.hidden = !visible;
element.classList.toggle("hidden", !visible);
}
function setMessage(element, message, isError = false) {
if (!element) return;
element.textContent = message || "";
element.hidden = !message;
element.classList.toggle("hidden", !message);
element.dataset.status = isError ? "error" : "success";
}
function setFormBusy(form, busy) {
form?.querySelectorAll("[type=submit]").forEach((button) => {
button.disabled = busy;
});
}
async function registerPasskey(element) {
setState(element, "loading", element.dataset.loadingMessage || "Preparing passkey…");
try {
if (!window.PublicKeyCredential || !navigator.credentials || !window.AbortController) {
throw new Error("Passkeys are not supported in this browser");
}
const controller = new window.AbortController();
requestControllers.get(element)?.abort();
requestControllers.set(element, controller);
const optionsPayload = await json(
element.dataset.optionsEndpoint || "/api/auth/passkeys/register/options",
{},
controller.signal,
);
const credential = await navigator.credentials.create({
publicKey: publicKeyCreationOptions(optionsPayload.options),
signal: controller.signal,
});
const result = await json(
element.dataset.verifyEndpoint || "/api/auth/passkeys/register/verify",
{
key: optionsPayload.key,
response: credentialToJson(credential),
name: element.dataset.passkeyName || "Passkey",
},
controller.signal,
);
setState(element, "success", element.dataset.successMessage || "Passkey added.");
dispatch(element, "auth:passkey-registered", result);
} catch (error) {
if (error && error.name === "AbortError") return;
setState(
element,
"error",
error instanceof Error ? error.message : "Passkey registration failed",
);
dispatch(element, "auth:error", { error });
}
}
async function authenticatePasskey(element) {
setState(element, "loading", element.dataset.loadingMessage || "Waiting for your passkey…");
try {
if (!window.PublicKeyCredential || !navigator.credentials || !window.AbortController) {
throw new Error("Passkeys are not supported in this browser");
}
const controller = new window.AbortController();
requestControllers.get(element)?.abort();
requestControllers.set(element, controller);
const identifier =
element.dataset.identifier ||
element.closest("form")?.querySelector("[name=identifier]")?.value ||
element.closest("[data-auth-sign-in]")?.querySelector("form [name=identifier]")?.value ||
undefined;
const optionsPayload = await json(
element.dataset.optionsEndpoint || "/api/auth/passkeys/login/options",
{ identifier },
controller.signal,
);
const credential = await navigator.credentials.get({
publicKey: publicKeyRequestOptions(optionsPayload.options),
signal: controller.signal,
mediation: element.dataset.conditional === "true" ? "conditional" : "optional",
});
const result = await json(
element.dataset.verifyEndpoint || "/api/auth/passkeys/login/verify",
{
key: optionsPayload.key,
response: credentialToJson(credential),
},
controller.signal,
);
setState(element, "success", element.dataset.successMessage || "Signed in.");
dispatch(element, "auth:passkey-authenticated", result);
if (element.dataset.redirect) navigate(element.dataset.redirect);
} catch (error) {
if (error && error.name === "AbortError") return;
const detail = error?.payload || {};
if (detail.code === "mfa-required" && detail.mfaToken) {
sessionStorage.setItem(MFA_STORAGE_KEY, JSON.stringify(detail));
dispatch(element, "auth:mfa-required", detail);
navigate(element.dataset.mfaHref || "/two-factor");
return;
}
setState(element, "error", error instanceof Error ? error.message : "Passkey sign-in failed");
dispatch(element, "auth:error", { error, ...detail });
}
}
function recoveryCodes(element) {
return Array.from(element.querySelectorAll("[data-recovery-code]"))
.map((item) => String(item.textContent || "").trim())
.filter(Boolean);
}
function renderRecoveryCodes(element, codes) {
const list = element.querySelector("[data-recovery-code-list]");
if (!list || !Array.isArray(codes)) return;
list.textContent = "";
for (const code of codes) {
const item = document.createElement("code");
item.dataset.recoveryCode = "";
item.className = "rounded bg-[var(--wire-color-surface)] px-2 py-1.5 text-center";
item.textContent = String(code);
list.appendChild(item);
}
}
function downloadRecoveryCodes(element) {
const codes = recoveryCodes(element);
if (!codes.length) return;
const blob = new Blob([codes.join("\n") + "\n"], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = element.dataset.recoveryFilename || "wrnexus-recovery-codes.txt";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function mountRecoveryCodes(element) {
if (!(element instanceof HTMLElement) || recoveryMounted.has(element)) return;
recoveryMounted.add(element);
const download = element.querySelector("[data-recovery-download]");
const onDownload = () => downloadRecoveryCodes(element);
const onSuccess = (event) => {
if (event.target instanceof HTMLFormElement && event.detail?.codes) {
renderRecoveryCodes(element, event.detail.codes);
}
};
download?.addEventListener("click", onDownload);
element.addEventListener("wire:success", onSuccess);
recoveryCleanups.set(element, () => {
download?.removeEventListener("click", onDownload);
element.removeEventListener("wire:success", onSuccess);
});
}
function mountOtpSignIn(element) {
if (!(element instanceof HTMLElement) || flowMounted.has(element)) return;
flowMounted.add(element);
const requestForm = element.querySelector("[data-auth-otp-request]");
const completeForm = element.querySelector("[data-auth-otp-complete]");
const back = element.querySelector("[data-auth-otp-back]");
const onSuccess = (event) => {
if (event.target !== requestForm || !event.detail?.id) return;
const challenge = completeForm?.querySelector("[name=challengeId]");
if (challenge) challenge.value = String(event.detail.id);
toggle(requestForm, false);
toggle(completeForm, true);
completeForm?.querySelector("[name=code]")?.focus();
dispatch(element, "auth:otp-requested", event.detail);
};
const onError = (event) => {
if (event.target !== completeForm) return;
const detail = event.detail || {};
if (detail.code !== "mfa-required" || !detail.mfaToken) return;
sessionStorage.setItem(MFA_STORAGE_KEY, JSON.stringify(detail));
dispatch(element, "auth:mfa-required", detail);
navigate(element.dataset.mfaHref || "/two-factor");
};
const onBack = () => {
toggle(completeForm, false);
toggle(requestForm, true);
requestForm?.querySelector("[name=identifier]")?.focus();
};
element.addEventListener("wire:success", onSuccess);
element.addEventListener("wire:error", onError);
back?.addEventListener("click", onBack);
flowCleanups.set(element, () => {
element.removeEventListener("wire:success", onSuccess);
element.removeEventListener("wire:error", onError);
back?.removeEventListener("click", onBack);
});
}
function mountSignIn(element) {
if (!(element instanceof HTMLElement) || flowMounted.has(element)) return;
flowMounted.add(element);
const form = element.querySelector("form[data-schema]");
const fingerprintInput = form?.querySelector("[name=deviceFingerprint]");
const deviceNameInput = form?.querySelector("[name=deviceName]");
if (fingerprintInput && !fingerprintInput.value) fingerprintInput.value = deviceFingerprint();
if (deviceNameInput && !deviceNameInput.value) {
deviceNameInput.value = String(navigator.userAgent || navigator.platform || "Browser").slice(
0,
120,
);
}
const onError = (event) => {
if (!(event.target instanceof HTMLFormElement)) return;
const detail = event.detail || {};
if (detail.code !== "mfa-required" || !detail.mfaToken) return;
sessionStorage.setItem(MFA_STORAGE_KEY, JSON.stringify(detail));
dispatch(element, "auth:mfa-required", detail);
navigate(element.dataset.mfaHref || "/two-factor");
};
element.addEventListener("wire:error", onError);
flowCleanups.set(element, () => element.removeEventListener("wire:error", onError));
}
function readMfaState() {
try {
return JSON.parse(sessionStorage.getItem(MFA_STORAGE_KEY) || "null");
} catch {
return null;
}
}
function mountMfaChallenge(element) {
if (!(element instanceof HTMLElement) || flowMounted.has(element)) return;
flowMounted.add(element);
const form = element.querySelector("[data-auth-mfa-form]");
const tokenInput = form?.querySelector("[name=mfaToken]");
const challengeInput = form?.querySelector("[name=challengeId]");
const methodInput = form?.querySelector("[data-auth-mfa-method]");
const codeInput = form?.querySelector("[name=code]");
const status = form?.querySelector("[data-auth-mfa-status]");
const saved = readMfaState();
if (tokenInput && !tokenInput.value && saved?.mfaToken) {
tokenInput.value = String(saved.mfaToken);
}
const allowed = Array.isArray(saved?.requires?.mfa) ? saved.requires.mfa : [];
const initialMethod = element.dataset.mfaInitialMethod;
if (methodInput && allowed.length) {
Array.from(methodInput.options).forEach((option) => {
option.disabled = !allowed.includes(option.value);
option.hidden = option.disabled;
});
const selected = allowed.includes(initialMethod)
? initialMethod
: allowed.includes(methodInput.value)
? methodInput.value
: allowed[0];
if (selected) methodInput.value = selected;
} else if (methodInput && initialMethod) {
methodInput.value = initialMethod;
}
const onMethodChange = () => {
if (challengeInput) challengeInput.value = "";
setMessage(status, "");
if (codeInput) {
codeInput.value = "";
codeInput.setAttribute(
"inputmode",
methodInput?.value === "recovery-code" ? "text" : "numeric",
);
}
};
const onSubmit = async (event) => {
const method = methodInput?.value;
if (!form || !["email-otp", "sms-otp"].includes(method) || challengeInput?.value) return;
event.preventDefault();
event.stopImmediatePropagation();
if (!tokenInput?.value) {
setMessage(status, "Sign-in verification has expired. Start again.", true);
return;
}
setFormBusy(form, true);
setMessage(status, "Sending a one-time code…");
try {
const result = await json(element.dataset.mfaOtpAction || "/api/auth/mfa/otp", {
mfaToken: tokenInput.value,
method,
});
if (challengeInput) challengeInput.value = String(result.id || "");
setMessage(status, "A one-time code has been sent.");
codeInput?.focus();
} catch (error) {
setMessage(
status,
error instanceof Error ? error.message : "Unable to send a one-time code",
true,
);
} finally {
setFormBusy(form, false);
}
};
const onSuccess = (event) => {
if (event.target !== form) return;
sessionStorage.removeItem(MFA_STORAGE_KEY);
};
onMethodChange();
methodInput?.addEventListener("change", onMethodChange);
form?.addEventListener("submit", onSubmit, true);
element.addEventListener("wire:success", onSuccess);
flowCleanups.set(element, () => {
methodInput?.removeEventListener("change", onMethodChange);
form?.removeEventListener("submit", onSubmit, true);
element.removeEventListener("wire:success", onSuccess);
});
}
function mountAuthenticatorSetup(element) {
if (!(element instanceof HTMLElement) || flowMounted.has(element)) return;
flowMounted.add(element);
const startForm = element.querySelector("[data-auth-authenticator-start]");
const details = element.querySelector("[data-auth-authenticator-details]");
const confirmForm = element.querySelector("[data-auth-authenticator-confirm]");
const secret = element.querySelector("[data-auth-authenticator-secret]");
const uri = element.querySelector("[data-auth-authenticator-uri]");
const onSuccess = (event) => {
if (event.target === startForm && event.detail?.credentialId) {
const credential = confirmForm?.querySelector("[name=credentialId]");
if (credential) credential.value = String(event.detail.credentialId);
if (secret) secret.textContent = String(event.detail.secret || "");
if (uri) uri.textContent = String(event.detail.uri || "");
toggle(startForm, false);
toggle(details, true);
confirmForm?.querySelector("[name=code]")?.focus();
dispatch(element, "auth:authenticator-created", event.detail);
}
if (event.target === confirmForm) {
dispatch(element, "auth:authenticator-enabled", event.detail);
}
};
element.addEventListener("wire:success", onSuccess);
flowCleanups.set(element, () => element.removeEventListener("wire:success", onSuccess));
}
function mountElement(element) {
if (!(element instanceof HTMLElement) || mounted.has(element)) return;
mounted.add(element);
const action = element.dataset.authPasskey;
const button = element.matches("button") ? element : element.querySelector("button");
if (!button || !action) return;
const handler = () =>
action === "register" ? registerPasskey(element) : authenticatePasskey(element);
button.addEventListener("click", handler);
cleanups.set(element, () => button.removeEventListener("click", handler));
}
function mount(root = document) {
if (root instanceof HTMLElement && root.matches("[data-auth-passkey]")) mountElement(root);
root.querySelectorAll?.("[data-auth-passkey]").forEach(mountElement);
if (root instanceof HTMLElement && root.matches("[data-auth-recovery-codes]"))
mountRecoveryCodes(root);
root.querySelectorAll?.("[data-auth-recovery-codes]").forEach(mountRecoveryCodes);
if (root instanceof HTMLElement && root.matches("[data-auth-otp-sign-in]"))
mountOtpSignIn(root);
root.querySelectorAll?.("[data-auth-otp-sign-in]").forEach(mountOtpSignIn);
if (root instanceof HTMLElement && root.matches("[data-auth-sign-in]")) mountSignIn(root);
root.querySelectorAll?.("[data-auth-sign-in]").forEach(mountSignIn);
if (root instanceof HTMLElement && root.matches("[data-auth-mfa-challenge]"))
mountMfaChallenge(root);
root.querySelectorAll?.("[data-auth-mfa-challenge]").forEach(mountMfaChallenge);
if (root instanceof HTMLElement && root.matches("[data-auth-authenticator-setup]"))
mountAuthenticatorSetup(root);
root.querySelectorAll?.("[data-auth-authenticator-setup]").forEach(mountAuthenticatorSetup);
}
function unmount(root = document) {
const elements = [];
if (root instanceof HTMLElement && root.matches("[data-auth-passkey]")) elements.push(root);
root.querySelectorAll?.("[data-auth-passkey]").forEach((element) => elements.push(element));
for (const element of elements) {
requestControllers.get(element)?.abort();
requestControllers.delete(element);
cleanups.get(element)?.();
cleanups.delete(element);
mounted.delete(element);
}
const recoveryElements = [];
if (root instanceof HTMLElement && root.matches("[data-auth-recovery-codes]"))
recoveryElements.push(root);
root
.querySelectorAll?.("[data-auth-recovery-codes]")
.forEach((element) => recoveryElements.push(element));
for (const element of recoveryElements) {
recoveryCleanups.get(element)?.();
recoveryCleanups.delete(element);
recoveryMounted.delete(element);
}
const flowElements = [];
const selector =
"[data-auth-otp-sign-in],[data-auth-sign-in],[data-auth-mfa-challenge],[data-auth-authenticator-setup]";
if (root instanceof HTMLElement && root.matches(selector)) flowElements.push(root);
root.querySelectorAll?.(selector).forEach((element) => flowElements.push(element));
for (const element of flowElements) {
flowCleanups.get(element)?.();
flowCleanups.delete(element);
flowMounted.delete(element);
}
}
window.WRNexusAuth = { mount, unmount, registerPasskey, authenticatePasskey };
window.__wrnexusRuntimes = window.__wrnexusRuntimes || {};
window.__wrnexusRuntimes.auth = { mount, unmount };
if (document.readyState === "loading")
document.addEventListener("DOMContentLoaded", () => mount(document), { once: true });
else mount(document);
new MutationObserver((records) => {
for (const record of records)
for (const node of record.addedNodes) if (node instanceof HTMLElement) mount(node);
}).observe(document.documentElement, { childList: true, subtree: true });
})();
@@ -0,0 +1,11 @@
component AccountStatus {
props { status = "active" title = "Account status" activeMessage = "Your account is active and ready to use." pendingMessage = "Verify your contact details to activate your account." lockedMessage = "Your account is temporarily locked for security." disabledMessage = "Your account has been disabled." supportHref = "/support" color = "primary" size = "md" class = "" }
view {
<section {...attrs} data-status='{status}' class='w-full max-w-lg rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-center {class}'>
<span class='mx-auto flex size-14 items-center justify-center rounded-full bg-[var(--wire-color-surface-2)] text-[var(--wire-color-primary)] data-[status=locked]:text-[var(--wire-color-warning)] data-[status=disabled]:text-[var(--wire-color-danger)]'>{#if status == "active"}<span class="icon-[lucide--circle-check] size-7"></span>{:else if status == "pending"}<span class="icon-[lucide--clock-3] size-7"></span>{:else}<span class="icon-[lucide--shield-alert] size-7"></span>{/if}</span>
<h1 class="mb-0 mt-4 text-2xl font-semibold">{title}</h1>
<p class="mx-auto mt-2 max-w-md text-sm text-[var(--wire-color-muted)]">{status == "active" ? activeMessage : status == "pending" ? pendingMessage : status == "locked" ? lockedMessage : disabledMessage}</p>
{#if status != "active"}<a href='{supportHref}' class="mt-4 inline-flex h-10 items-center justify-center rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white">Contact support</a>{/if}
</section>
}
}
@@ -0,0 +1,69 @@
component AuthenticatorSetup {
props {
setupAction = "/api/auth/totp/setup"
setupSchema = "auth-authenticator-setup"
action = "/api/auth/totp/confirm"
schema = "auth-authenticator-confirm"
credentialId = ""
secret = ""
uri = ""
title = "Set up an authenticator"
description = "Create a setup key, add it to your authenticator app, then enter a current code."
setupLabel = "Create setup key"
submitLabel = "Enable authenticator"
successMessage = "Authenticator enabled."
color = "primary"
size = "md"
class = ""
}
view {
<section
{...attrs}
data-wrnexus-runtime="auth"
data-auth-authenticator-setup
class='w-full max-w-lg rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] {class}'
>
<div class="flex items-start gap-4">
<span class="flex size-11 shrink-0 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--wire-color-primary)_12%,transparent)] text-[var(--wire-color-primary)]"><span class="icon-[lucide--scan-line] size-5"></span></span>
<div><h1 class="m-0 text-xl font-semibold">{title}</h1><p class="mb-0 mt-1 text-sm text-[var(--wire-color-muted)]">{description}</p></div>
</div>
<form
method="post"
action='{setupAction}'
data-schema='{setupSchema}'
data-auth-authenticator-start
novalidate
class='mt-5 space-y-4 {credentialId == "" ? "" : "hidden"}'
>
<div class="space-y-1.5">
<label for="auth-authenticator-label" class="block text-sm font-medium">Authenticator name</label>
<input id="auth-authenticator-label" name="label" value="Authenticator" autocomplete="off" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" />
<p data-error="label" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] text-sm font-semibold text-white disabled:opacity-60">{setupLabel}</button>
</form>
<div data-auth-authenticator-details class='{credentialId == "" ? "hidden" : ""}'>
<div class="mt-5 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] p-4">
<p class="m-0 text-xs font-semibold uppercase tracking-wide text-[var(--wire-color-muted)]">Manual setup key</p>
<code data-auth-authenticator-secret class="mt-2 block break-all text-sm font-semibold tracking-wider">{secret}</code>
<code data-auth-authenticator-uri class="mt-3 block max-h-24 overflow-auto break-all text-xs text-[var(--wire-color-muted)]">{uri}</code>
</div>
<form method="post" action='{action}' data-schema='{schema}' data-auth-authenticator-confirm novalidate class="mt-5 space-y-4">
<input type="hidden" name="credentialId" value='{credentialId}' />
<div class="space-y-1.5">
<label for="auth-authenticator-code" class="block text-sm font-medium">Authenticator code</label>
<input id="auth-authenticator-code" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="h-12 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-center text-xl tracking-[0.35em]" />
<p data-error="code" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<p data-error="credentialId" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] text-sm font-semibold text-white disabled:opacity-60">{submitLabel}</button>
</form>
</div>
</section>
}
}
@@ -0,0 +1,40 @@
component DeviceSessions {
props {
sessions = []
currentSessionId = ""
title = "Active sessions"
description = "Review devices signed in to your account."
revokeAction = "/api/auth/sessions/revoke"
revokeSchema = "auth-session-revoke"
revokeLabel = "Sign out"
successMessage = "Session revoked."
color = "primary"
size = "md"
class = ""
}
view {
<section {...attrs} data-wrnexus-runtime="auth" class='w-full max-w-2xl rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] {class}'>
<h2 class="m-0 text-xl font-semibold">{title}</h2>
<p class="mt-1 text-sm text-[var(--wire-color-muted)]">{description}</p>
<div class="mt-5 divide-y divide-[var(--wire-color-border)]">
{#each sessions as session}
<article class="flex items-center gap-3 py-4">
<span class="flex size-10 items-center justify-center rounded-full bg-[var(--wire-color-surface-2)]"><span class="icon-[lucide--monitor-smartphone] size-5"></span></span>
<div class="min-w-0 flex-1"><p class="m-0 truncate text-sm font-semibold">{session.userAgent || "Unknown device"}</p><p class="m-0 mt-1 text-xs text-[var(--wire-color-muted)]">{session.ip || "Unknown IP"} · Last active {session.lastSeenAt}</p></div>
{#if session.id == currentSessionId}
<span class="rounded-full bg-[color-mix(in_srgb,var(--wire-color-success)_12%,transparent)] px-2 py-1 text-xs font-semibold text-[var(--wire-color-success)]">Current</span>
{:else}
<form method="post" action='{revokeAction}' data-schema='{revokeSchema}' novalidate>
<input type="hidden" name="sessionId" value='{session.id}' />
<p data-error="sessionId" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden text-xs text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 text-xs text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="text-sm font-semibold text-[var(--wire-color-danger)] disabled:opacity-60">{revokeLabel}</button>
</form>
{/if}
</article>
{/each}
</div>
</section>
}
}
@@ -0,0 +1,32 @@
component ForgotPassword {
props {
action = "/api/auth/password/request"
schema = "auth-password-request"
title = "Recover your account"
description = "Enter your email, phone, or username."
identifierLabel = "Account identifier"
submitLabel = "Send recovery link"
successMessage = "If an account matches, a recovery link has been sent."
signInHref = "/sign-in"
color = "primary"
size = "md"
class = ""
}
view {
<section {...attrs} data-wrnexus-runtime="auth" class='w-full max-w-md rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-2)] {class}'>
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
<p class="mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
<form method="post" action='{action}' data-schema='{schema}' novalidate class="mt-6 space-y-4">
<div class="space-y-1.5">
<label for="auth-recovery-identifier" class="block text-sm font-medium">{identifierLabel}</label>
<input id="auth-recovery-identifier" name="identifier" autocomplete="username" aria-describedby="auth-recovery-identifier-error" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-sm outline-none focus:border-[var(--wire-color-primary)]" />
<p id="auth-recovery-identifier-error" data-error="identifier" role="alert" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
</form>
<a href='{signInHref}' class="mt-4 inline-flex text-sm text-[var(--wire-color-primary)] hover:underline">Back to sign in</a>
</section>
}
}
@@ -0,0 +1,22 @@
component ImpersonationBanner {
props {
visible = true
targetName = "this user"
stopAction = "/api/auth/impersonation/stop"
stopSchema = "auth-empty"
redirect = "/account"
message = "You are viewing the application as"
stopLabel = "Stop impersonating"
color = "warning"
size = "md"
class = ""
}
view {
{#if visible}
<aside {...attrs} data-wrnexus-runtime="auth" role="status" class='flex w-full flex-wrap items-center justify-between gap-3 border-b border-[var(--wire-color-warning)] bg-[color-mix(in_srgb,var(--wire-color-warning)_14%,var(--wire-color-surface))] px-4 py-2 text-sm text-[var(--wire-color-text)] {class}'>
<span class="flex items-center gap-2"><span class="icon-[lucide--scan-face] size-4 text-[var(--wire-color-warning)]"></span><span>{message} <strong>{targetName}</strong>.</span></span>
<form method="post" action='{stopAction}' data-schema='{stopSchema}' data-redirect='{redirect}' novalidate><p data-error="_form" role="alert" class="m-0 hidden text-xs text-[var(--wire-color-danger)]"></p><button type="submit" class="inline-flex h-8 items-center rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-warning)] px-3 text-xs font-semibold text-black disabled:opacity-60">{stopLabel}</button></form>
</aside>
{/if}
}
}
@@ -0,0 +1,34 @@
component InvitationAccept {
props {
action = "/api/auth/invitations/accept"
schema = "auth-invitation"
token = ""
redirect = "/account"
organization = "your workspace"
inviter = "A workspace administrator"
title = "Accept your invitation"
description = "Create your password to join the workspace."
submitLabel = "Accept invitation"
successMessage = "Invitation accepted."
color = "primary"
size = "md"
class = ""
}
view {
<section {...attrs} data-wrnexus-runtime="auth" class='w-full max-w-md rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-2)] {class}'>
<span class="flex size-11 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--wire-color-primary)_12%,transparent)] text-[var(--wire-color-primary)]"><span class="icon-[lucide--user-round-plus] size-5"></span></span>
<h1 class="mb-0 mt-4 text-2xl font-semibold">{title}</h1>
<p class="mb-0 mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
<div class="mt-5 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4 text-sm"><p class="m-0 font-semibold">{organization}</p><p class="mb-0 mt-1 text-[var(--wire-color-muted)]">Invited by {inviter}</p></div>
<form method="post" action='{action}' data-schema='{schema}' data-redirect='{redirect}' novalidate class="mt-5 space-y-4">
<input type="hidden" name="token" value='{token}' />
<div class="space-y-1.5"><label for="auth-invitation-name" class="block text-sm font-medium">Display name</label><input id="auth-invitation-name" name="displayName" autocomplete="name" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /><p data-error="displayName" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
<div class="space-y-1.5"><label for="auth-invitation-password" class="block text-sm font-medium">Create password</label><input id="auth-invitation-password" name="password" type="password" autocomplete="new-password" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /><p data-error="password" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
<p data-error="token" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
</form>
</section>
}
}
@@ -0,0 +1,29 @@
component MagicLinkSignIn {
props {
action = "/api/auth/magic-link/request"
schema = "auth-magic-link-request"
title = "Email me a sign-in link"
description = "Receive a secure, single-use link that expires automatically."
emailLabel = "Email address"
submitLabel = "Send sign-in link"
successMessage = "If an account matches, a sign-in link has been sent."
signInHref = "/sign-in"
color = "primary"
size = "md"
class = ""
}
view {
<section {...attrs} data-wrnexus-runtime="auth" class='w-full max-w-md rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-2)] {class}'>
<div class="mb-5 flex size-11 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--wire-color-primary)_12%,transparent)] text-[var(--wire-color-primary)]"><span class="icon-[lucide--mail-check] size-5"></span></div>
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
<p class="mb-0 mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
<form method="post" action='{action}' data-schema='{schema}' novalidate class="mt-6 space-y-4">
<div class="space-y-1.5"><label for="auth-magic-link-identifier" class="block text-sm font-medium">{emailLabel}</label><input id="auth-magic-link-identifier" name="identifier" type="email" autocomplete="email" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-sm outline-none focus:border-[var(--wire-color-primary)]" /><p data-error="identifier" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
</form>
<a href='{signInHref}' class="mt-4 inline-flex text-sm font-medium text-[var(--wire-color-primary)] hover:underline">Return to password sign-in</a>
</section>
}
}
+77
View File
@@ -0,0 +1,77 @@
component OtpSignIn {
props {
requestAction = "/api/auth/otp/login/request"
requestSchema = "auth-otp-login-request"
completeAction = "/api/auth/otp/login/complete"
completeSchema = "auth-otp-login-complete"
challengeId = ""
returnTo = "/"
mfaHref = "/two-factor"
method = "email-otp"
title = "Sign in with a code"
description = "We will send a one-time code to your email address or phone number."
identifierLabel = "Email address or phone number"
requestLabel = "Send code"
verifyLabel = "Verify and sign in"
successMessage = "A one-time code has been sent."
color = "primary"
size = "md"
class = ""
}
view {
<section
{...attrs}
data-wrnexus-runtime="auth"
data-auth-otp-sign-in
data-mfa-href='{mfaHref}'
class='w-full max-w-md rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-2)] {class}'
>
<div class="mb-5 flex size-11 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--wire-color-primary)_12%,transparent)] text-[var(--wire-color-primary)]">
<span class="icon-[lucide--key-round] size-5"></span>
</div>
<h1 class="m-0 text-2xl font-semibold tracking-tight">{title}</h1>
<p class="mb-0 mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
<form
method="post"
action='{requestAction}'
data-schema='{requestSchema}'
data-auth-otp-request
novalidate
class='mt-6 space-y-4 {challengeId == "" ? "" : "hidden"}'
>
<input type="hidden" name="method" value='{method}' />
<div class="space-y-1.5">
<label for="auth-otp-identifier" class="block text-sm font-medium">{identifierLabel}</label>
<input id="auth-otp-identifier" name="identifier" type="text" autocomplete="username" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-sm outline-none focus:border-[var(--wire-color-primary)]" />
<p data-error="identifier" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<p data-error="method" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{requestLabel}</button>
</form>
<form
method="post"
action='{completeAction}'
data-schema='{completeSchema}'
data-auth-otp-complete
novalidate
class='mt-6 space-y-4 {challengeId == "" ? "hidden" : ""}'
>
<input type="hidden" name="challengeId" value='{challengeId}' />
<input type="hidden" name="returnTo" value='{returnTo}' />
<div class="space-y-1.5">
<label for="auth-otp-code" class="block text-sm font-medium">One-time code</label>
<input id="auth-otp-code" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="h-12 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-center text-xl tracking-[0.35em] outline-none focus:border-[var(--wire-color-primary)]" />
<p data-error="code" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<p data-error="challengeId" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{verifyLabel}</button>
<button type="button" data-auth-otp-back class="w-full text-sm text-[var(--wire-color-primary)] hover:underline">Use a different account</button>
</form>
</section>
}
}
@@ -0,0 +1,34 @@
component PasskeyButton {
props {
mode = "authenticate"
label = "Continue with a passkey"
registerLabel = "Add a passkey"
identifier = ""
rpId = ""
rpName = "WRNexusJS"
passkeyName = "Passkey"
optionsEndpoint = ""
verifyEndpoint = ""
redirect = ""
mfaHref = "/two-factor"
conditional = false
fullWidth = false
loadingMessage = "Waiting for your passkey…"
successMessage = "Passkey verified."
color = "primary"
size = "md"
class = ""
@event passkeyRegistered = function
@event passkeyAuthenticated = function
@event error = function
}
view {
<div {...attrs} data-wrnexus-runtime="auth" data-auth-passkey='{mode}' data-identifier='{identifier}' data-rp-id='{rpId}' data-rp-name='{rpName}' data-passkey-name='{passkeyName}' data-options-endpoint='{optionsEndpoint}' data-verify-endpoint='{verifyEndpoint}' data-redirect='{redirect}' data-mfa-href='{mfaHref}' data-conditional='{conditional}' data-loading-message='{loadingMessage}' data-success-message='{successMessage}' data-auth-state="idle" aria-busy="false" class='space-y-2 {class}'>
<button type="button" class='inline-flex h-11 items-center justify-center gap-2 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] px-4 text-sm font-semibold text-[var(--wire-color-text)] hover:bg-[var(--wire-color-surface-2)] disabled:cursor-wait disabled:opacity-60 {fullWidth ? "w-full" : ""}'>
<span aria-hidden="true" class="icon-[lucide--key-round] size-4"></span>
{mode == "register" ? registerLabel : label}
</button>
<p data-auth-status hidden role="status" class="m-0 text-xs text-[var(--wire-color-muted)]"></p>
</div>
}
}
@@ -0,0 +1,86 @@
component RecoveryCodes {
props {
codes = []
schema = "auth-recovery-codes"
title = "Recovery codes"
description = "Store these codes somewhere safe. Each code can be used once."
downloadLabel = "Download codes"
regenerateLabel = "Generate new codes"
regenerateAction = "/api/auth/recovery-codes"
successMessage = "New recovery codes generated. Previous unused codes are no longer valid."
count = 10
color = "primary"
size = "md"
class = ""
}
view {
<section
{...attrs}
data-wrnexus-runtime="auth"
data-auth-recovery-codes
data-recovery-filename="wrnexus-recovery-codes.txt"
class='w-full max-w-xl rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 {class}'
>
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="m-0 text-xl font-semibold">{title}</h2>
<p class="mt-1 text-sm text-[var(--wire-color-muted)]">{description}</p>
</div>
<span class="icon-[lucide--shield-keyhole] size-6 text-[var(--wire-color-primary)]"></span>
</div>
<div
data-recovery-code-list
class="mt-5 grid grid-cols-2 gap-2 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] p-4 font-mono text-sm sm:grid-cols-3"
>
{#each codes as code}
<code
data-recovery-code
class="rounded bg-[var(--wire-color-surface)] px-2 py-1.5 text-center"
>{code}</code>
{/each}
</div>
<div class="mt-4 flex flex-wrap gap-2">
<button
type="button"
data-recovery-download
class="h-10 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] px-4 text-sm font-semibold"
>
{downloadLabel}
</button>
<form
method="post"
action='{regenerateAction}'
data-schema='{schema}'
novalidate
class="flex-1"
>
<input type="hidden" name="count" value='{count}' />
<p data-error="count" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p
data-error="_form"
role="alert"
class="m-0 hidden text-xs text-[var(--wire-color-danger)]"
></p>
<p
data-success='{successMessage}'
role="status"
hidden
class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-xs text-[var(--wire-color-success)]"
>
{successMessage}
</p>
<button
type="submit"
class="h-10 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] px-4 text-sm font-semibold disabled:cursor-wait disabled:opacity-60"
>
{regenerateLabel}
</button>
</form>
</div>
</section>
}
}
@@ -0,0 +1,34 @@
component ResetPassword {
props {
action = "/api/auth/password/reset"
schema = "auth-password-reset"
token = ""
redirect = "/sign-in"
title = "Set a new password"
description = "Choose a strong password you have not used before."
submitLabel = "Reset password"
successMessage = "Your password has been reset."
color = "primary"
size = "md"
class = ""
}
view {
<section {...attrs} data-wrnexus-runtime="auth" class='w-full max-w-md rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-2)] {class}'>
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
<p class="mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
<form method="post" action='{action}' data-schema='{schema}' data-redirect='{redirect}' novalidate class="mt-6 space-y-4">
<input type="hidden" name="token" value='{token}' />
<div class="space-y-1.5">
<label for="auth-reset-password" class="block text-sm font-medium">New password</label>
<input id="auth-reset-password" name="password" type="password" autocomplete="new-password" aria-describedby="auth-reset-password-error" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
<p class="m-0 text-xs text-[var(--wire-color-muted)]">At least 12 characters with uppercase, lowercase, and a number.</p>
<p id="auth-reset-password-error" data-error="password" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<p data-error="token" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
</form>
</section>
}
}
+58
View File
@@ -0,0 +1,58 @@
component SignIn {
props {
action = "/api/auth/login"
schema = "auth-login"
returnTo = "/"
redirect = ""
mfaHref = "/two-factor"
title = "Welcome back"
description = "Sign in to continue to your account."
identifierLabel = "Email, phone, or username"
passwordLabel = "Password"
submitLabel = "Sign in"
forgotHref = "/recover"
signUpHref = "/sign-up"
showRemember = true
showPasskey = true
showSignUp = true
color = "primary"
size = "md"
class = ""
}
view {
<section {...attrs} data-wrnexus-runtime="auth" data-auth-sign-in data-mfa-href='{mfaHref}' class='w-full max-w-md rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-2)] {class}'>
<header class="mb-6 space-y-1.5">
<h1 class="m-0 text-2xl font-semibold tracking-tight">{title}</h1>
<p class="m-0 text-sm text-[var(--wire-color-muted)]">{description}</p>
</header>
<form method="post" action='{action}' data-schema='{schema}' data-redirect='{redirect}' novalidate class="space-y-4">
<input type="hidden" name="returnTo" value='{returnTo}' />
<input type="hidden" name="deviceFingerprint" value="" />
<input type="hidden" name="deviceName" value="" />
<div class="space-y-1.5">
<label for="auth-sign-in-identifier" class="block text-sm font-medium">{identifierLabel}</label>
<input id="auth-sign-in-identifier" name="identifier" type="text" autocomplete="username webauthn" aria-describedby="auth-sign-in-identifier-error" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-sm outline-none focus:border-[var(--wire-color-primary)] focus:ring-2 focus:ring-[color-mix(in_srgb,var(--wire-color-primary)_25%,transparent)]" />
<p id="auth-sign-in-identifier-error" data-error="identifier" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-3 text-sm font-medium"><label for="auth-sign-in-password">{passwordLabel}</label><a href='{forgotHref}' class="text-xs text-[var(--wire-color-primary)] hover:underline">Forgot password?</a></div>
<input id="auth-sign-in-password" name="password" type="password" autocomplete="current-password" aria-describedby="auth-sign-in-password-error" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-sm outline-none focus:border-[var(--wire-color-primary)] focus:ring-2 focus:ring-[color-mix(in_srgb,var(--wire-color-primary)_25%,transparent)]" />
<p id="auth-sign-in-password-error" data-error="password" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
{#if showRemember}
<label class="flex items-center gap-2 text-sm text-[var(--wire-color-muted)]"><input name="rememberDevice" type="checkbox" class="size-4 rounded border-[var(--wire-color-border)]" />Trust this device</label>
{/if}
<slot></slot>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<button type="submit" class="inline-flex h-11 w-full items-center justify-center rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
</form>
{#if showPasskey}
<div class="my-4 flex items-center gap-3 text-xs text-[var(--wire-color-muted)]"><span class="h-px flex-1 bg-[var(--wire-color-border)]"></span><span>or</span><span class="h-px flex-1 bg-[var(--wire-color-border)]"></span></div>
<PasskeyButton mode="authenticate" identifier="" mfaHref='{mfaHref}' fullWidth="true" />
{/if}
{#if showSignUp}
<p class="mb-0 mt-5 text-center text-sm text-[var(--wire-color-muted)]">New here? <a href='{signUpHref}' class="font-semibold text-[var(--wire-color-primary)] hover:underline">Create an account</a></p>
{/if}
</section>
}
}
+90
View File
@@ -0,0 +1,90 @@
component SignUp {
props {
action = "/api/auth/register"
schema = "auth-register"
redirect = ""
title = "Create your account"
description = "Use a strong password and verify your contact details."
submitLabel = "Create account"
signInHref = "/sign-in"
showPhone = true
showUsername = true
requireConsent = true
color = "primary"
size = "md"
class = ""
}
view {
<section {...attrs} data-wrnexus-runtime="auth" class='w-full max-w-lg rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-2)] {class}'>
<header class="mb-6 space-y-1.5">
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
<p class="m-0 text-sm text-[var(--wire-color-muted)]">{description}</p>
</header>
<form
method="post"
action='{action}'
data-schema='{schema}'
data-redirect='{redirect}'
novalidate
class="grid gap-4 sm:grid-cols-2"
>
<div class="space-y-1.5 sm:col-span-2">
<label for="auth-sign-up-name" class="text-sm font-medium">Full name</label>
<input id="auth-sign-up-name" name="displayName" autocomplete="name" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
<p data-error="displayName" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<div class="space-y-1.5">
<label for="auth-sign-up-email" class="text-sm font-medium">Email</label>
<input id="auth-sign-up-email" name="email" type="email" autocomplete="email" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
<p data-error="email" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
{#if showPhone}
<div class="space-y-1.5">
<label for="auth-sign-up-phone" class="text-sm font-medium">Phone</label>
<input id="auth-sign-up-phone" name="phone" type="tel" autocomplete="tel" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
<p data-error="phone" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
{/if}
{#if showUsername}
<div class="space-y-1.5 sm:col-span-2">
<label for="auth-sign-up-username" class="text-sm font-medium">Username</label>
<input id="auth-sign-up-username" name="username" autocomplete="username" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
<p data-error="username" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
{/if}
<div class="space-y-1.5 sm:col-span-2">
<label for="auth-sign-up-password" class="text-sm font-medium">Password</label>
<input id="auth-sign-up-password" name="password" type="password" autocomplete="new-password" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
<p class="m-0 text-xs text-[var(--wire-color-muted)]">At least 12 characters with uppercase, lowercase, and a number.</p>
<p data-error="password" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
{#if requireConsent}
<div class="space-y-1 sm:col-span-2">
<label class="flex items-start gap-2 text-sm text-[var(--wire-color-muted)]">
<input name="consent" type="checkbox" class="mt-0.5 size-4" />
<span>I agree to the terms and privacy policy.</span>
</label>
<p data-error="consent" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
{:else}
<input type="hidden" name="consent" value="true" />
{/if}
<div class="sm:col-span-2"><slot></slot></div>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)] sm:col-span-2"></p>
<p data-success="Account created successfully." role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)] sm:col-span-2"></p>
<button type="submit" class="h-11 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white sm:col-span-2 disabled:cursor-not-allowed disabled:opacity-60">{submitLabel}</button>
</form>
<p class="mb-0 mt-5 text-center text-sm text-[var(--wire-color-muted)]">Already have an account? <a href='{signInHref}' class="font-semibold text-[var(--wire-color-primary)] hover:underline">Sign in</a></p>
</section>
}
}
@@ -0,0 +1,58 @@
component TwoFactorChallenge {
props {
action = "/api/auth/mfa/complete"
schema = "auth-mfa"
otpAction = "/api/auth/mfa/otp"
mfaToken = ""
challengeId = ""
method = "totp"
returnTo = "/"
title = "Two-step verification"
description = "Choose a verification method and enter the code."
submitLabel = "Verify"
recoveryHref = "/recover"
color = "primary"
size = "md"
class = ""
}
view {
<section
{...attrs}
data-wrnexus-runtime="auth"
data-auth-mfa-challenge
data-mfa-otp-action='{otpAction}'
data-mfa-initial-method='{method}'
class='w-full max-w-md rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-2)] {class}'
>
<div class="mb-5 flex size-11 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--wire-color-primary)_12%,transparent)] text-[var(--wire-color-primary)]"><span class="icon-[lucide--shield-check] size-5"></span></div>
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
<p class="mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
<form method="post" action='{action}' data-schema='{schema}' data-auth-mfa-form novalidate class="mt-6 space-y-4">
<input type="hidden" name="mfaToken" value='{mfaToken}' />
<input type="hidden" name="challengeId" value='{challengeId}' />
<input type="hidden" name="returnTo" value='{returnTo}' />
<div class="space-y-1.5">
<label for="auth-mfa-method" class="block text-sm font-medium">Verification method</label>
<select id="auth-mfa-method" name="method" data-auth-mfa-method class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3">
<option value="totp">Authenticator app</option>
<option value="recovery-code">Recovery code</option>
<option value="email-otp">Email code</option>
<option value="sms-otp">SMS code</option>
</select>
<p data-error="method" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<div class="space-y-1.5">
<label for="auth-mfa-code" class="block text-sm font-medium">Verification code</label>
<input id="auth-mfa-code" name="code" inputmode="numeric" autocomplete="one-time-code" class="h-12 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-center text-xl tracking-[0.35em]" />
<p data-error="code" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
</div>
<p data-auth-mfa-status role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-primary)_10%,transparent)] p-3 text-sm text-[var(--wire-color-primary)]"></p>
<p data-error="mfaToken" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="challengeId" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
</form>
<a href='{recoveryHref}' class="mt-4 inline-flex text-sm text-[var(--wire-color-primary)] hover:underline">Account recovery options</a>
</section>
}
}
+37
View File
@@ -0,0 +1,37 @@
component VerifyEmail {
props {
kind = "email"
action = "/api/auth/verify/email"
schema = "auth-verification-token"
token = ""
identifier = ""
title = "Verify your email"
description = "Enter the verification token or continue with your secure link."
submitLabel = "Verify email"
resendAction = "/api/auth/verification/request"
resendSchema = "auth-verification-request"
successMessage = "Your email has been verified."
color = "primary"
size = "md"
class = ""
}
view {
<section {...attrs} data-wrnexus-runtime="auth" class='w-full max-w-md rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-2)] {class}'>
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
<p class="mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
<form method="post" action='{action}' data-schema='{schema}' novalidate class="mt-6 space-y-4">
<div class="space-y-1.5"><label for="auth-email-token" class="block text-sm font-medium">Verification token</label><input id="auth-email-token" name="token" value='{token}' autocomplete="one-time-code" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /><p data-error="token" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
</form>
<form method="post" action='{resendAction}' data-schema='{resendSchema}' novalidate class="mt-3">
<input type="hidden" name="type" value='{kind}' />
<input type="hidden" name="identifier" value='{identifier}' />
<p data-error="type" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden text-xs text-[var(--wire-color-danger)]"></p>
<button type="submit" class="text-sm text-[var(--wire-color-primary)] hover:underline disabled:opacity-60">Send a new code</button>
</form>
</section>
}
}
+37
View File
@@ -0,0 +1,37 @@
component VerifyPhone {
props {
kind = "phone"
action = "/api/auth/verify/phone"
schema = "auth-verification-token"
token = ""
identifier = ""
title = "Verify your phone"
description = "Enter the verification token sent to your phone."
submitLabel = "Verify phone"
resendAction = "/api/auth/verification/request"
resendSchema = "auth-verification-request"
successMessage = "Your phone number has been verified."
color = "primary"
size = "md"
class = ""
}
view {
<section {...attrs} data-wrnexus-runtime="auth" class='w-full max-w-md rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-2)] {class}'>
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
<p class="mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
<form method="post" action='{action}' data-schema='{schema}' novalidate class="mt-6 space-y-4">
<div class="space-y-1.5"><label for="auth-phone-token" class="block text-sm font-medium">Verification token</label><input id="auth-phone-token" name="token" value='{token}' autocomplete="one-time-code" inputmode="numeric" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /><p data-error="token" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)]"></p>
<p data-success='{successMessage}' role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)]">{successMessage}</p>
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
</form>
<form method="post" action='{resendAction}' data-schema='{resendSchema}' novalidate class="mt-3">
<input type="hidden" name="type" value='{kind}' />
<input type="hidden" name="identifier" value='{identifier}' />
<p data-error="type" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden text-xs text-[var(--wire-color-danger)]"></p>
<button type="submit" class="text-sm text-[var(--wire-color-primary)] hover:underline disabled:opacity-60">Send a new code</button>
</form>
</section>
}
}
+200
View File
@@ -0,0 +1,200 @@
-- +up
CREATE TABLE IF NOT EXISTS wrn_auth_users (
id VARCHAR(191) PRIMARY KEY,
username VARCHAR(191),
display_name VARCHAR(191),
avatar_url TEXT,
status VARCHAR(32) NOT NULL,
roles_json TEXT NOT NULL,
email_verified INTEGER NOT NULL DEFAULT 0,
phone_verified INTEGER NOT NULL DEFAULT 0,
mfa_enabled INTEGER NOT NULL DEFAULT 0,
locale VARCHAR(32),
timezone VARCHAR(64),
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
last_login_at BIGINT,
metadata_json TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS wrn_auth_users_username_uq ON wrn_auth_users(username);
CREATE TABLE IF NOT EXISTS wrn_auth_identities (
id VARCHAR(191) PRIMARY KEY,
user_id VARCHAR(191) NOT NULL,
type VARCHAR(32) NOT NULL,
value VARCHAR(320) NOT NULL,
normalized_value VARCHAR(320) NOT NULL,
is_primary INTEGER NOT NULL DEFAULT 0,
verified_at BIGINT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE(type, normalized_value)
);
CREATE INDEX IF NOT EXISTS wrn_auth_identities_user_idx ON wrn_auth_identities(user_id);
CREATE TABLE IF NOT EXISTS wrn_auth_password_credentials (
user_id VARCHAR(191) PRIMARY KEY,
password_hash TEXT NOT NULL,
password_version INTEGER NOT NULL DEFAULT 1,
changed_at BIGINT NOT NULL,
must_change INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS wrn_auth_sessions (
id VARCHAR(191) PRIMARY KEY,
user_id VARCHAR(191) NOT NULL,
device_id VARCHAR(191) NOT NULL,
created_at BIGINT NOT NULL,
last_seen_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
absolute_expires_at BIGINT NOT NULL,
ip VARCHAR(64),
user_agent TEXT,
trusted INTEGER NOT NULL DEFAULT 0,
revoked_at BIGINT,
revoke_reason VARCHAR(191),
metadata_json TEXT
);
CREATE INDEX IF NOT EXISTS wrn_auth_sessions_user_idx ON wrn_auth_sessions(user_id);
CREATE INDEX IF NOT EXISTS wrn_auth_sessions_expiry_idx ON wrn_auth_sessions(expires_at);
CREATE TABLE IF NOT EXISTS wrn_auth_trusted_devices (
id VARCHAR(191) PRIMARY KEY,
user_id VARCHAR(191) NOT NULL,
name VARCHAR(191) NOT NULL,
fingerprint_hash VARCHAR(191) NOT NULL,
created_at BIGINT NOT NULL,
last_seen_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
revoked_at BIGINT,
UNIQUE(user_id, fingerprint_hash)
);
CREATE TABLE IF NOT EXISTS wrn_auth_tokens (
id VARCHAR(191) PRIMARY KEY,
user_id VARCHAR(191) NOT NULL,
purpose VARCHAR(64) NOT NULL,
token_hash VARCHAR(191) NOT NULL UNIQUE,
target VARCHAR(320),
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
used_at BIGINT,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL,
metadata_json TEXT
);
CREATE INDEX IF NOT EXISTS wrn_auth_tokens_user_idx ON wrn_auth_tokens(user_id);
CREATE INDEX IF NOT EXISTS wrn_auth_tokens_expiry_idx ON wrn_auth_tokens(expires_at);
CREATE TABLE IF NOT EXISTS wrn_auth_otp_challenges (
id VARCHAR(191) PRIMARY KEY,
user_id VARCHAR(191) NOT NULL,
method VARCHAR(32) NOT NULL,
destination VARCHAR(320) NOT NULL,
code_hash VARCHAR(191) NOT NULL,
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
used_at BIGINT,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS wrn_auth_totp_credentials (
id VARCHAR(191) PRIMARY KEY,
user_id VARCHAR(191) NOT NULL,
label VARCHAR(191) NOT NULL,
secret TEXT NOT NULL,
created_at BIGINT NOT NULL,
verified_at BIGINT,
last_counter BIGINT
);
CREATE TABLE IF NOT EXISTS wrn_auth_recovery_codes (
id VARCHAR(191) PRIMARY KEY,
user_id VARCHAR(191) NOT NULL,
code_hash VARCHAR(191) NOT NULL,
created_at BIGINT NOT NULL,
used_at BIGINT
);
CREATE INDEX IF NOT EXISTS wrn_auth_recovery_user_idx ON wrn_auth_recovery_codes(user_id);
CREATE TABLE IF NOT EXISTS wrn_auth_passkeys (
id VARCHAR(191) PRIMARY KEY,
user_id VARCHAR(191) NOT NULL,
credential_id TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
counter BIGINT NOT NULL DEFAULT 0,
transports_json TEXT NOT NULL,
name VARCHAR(191) NOT NULL,
created_at BIGINT NOT NULL,
last_used_at BIGINT,
backed_up INTEGER,
device_type VARCHAR(64)
);
CREATE INDEX IF NOT EXISTS wrn_auth_passkeys_user_idx ON wrn_auth_passkeys(user_id);
CREATE TABLE IF NOT EXISTS wrn_auth_oauth_accounts (
id VARCHAR(191) PRIMARY KEY,
user_id VARCHAR(191) NOT NULL,
provider VARCHAR(64) NOT NULL,
provider_account_id VARCHAR(191) NOT NULL,
email VARCHAR(320),
access_token TEXT,
refresh_token TEXT,
token_expires_at BIGINT,
scope TEXT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE(provider, provider_account_id)
);
CREATE TABLE IF NOT EXISTS wrn_auth_login_attempts (
id VARCHAR(191) PRIMARY KEY,
identifier VARCHAR(320),
user_id VARCHAR(191),
success INTEGER NOT NULL,
reason VARCHAR(191),
ip VARCHAR(64),
user_agent TEXT,
created_at BIGINT NOT NULL,
risk_score INTEGER NOT NULL,
risk_level VARCHAR(32) NOT NULL
);
CREATE INDEX IF NOT EXISTS wrn_auth_login_attempts_identifier_idx ON wrn_auth_login_attempts(identifier, created_at);
CREATE TABLE IF NOT EXISTS wrn_auth_security_events (
id VARCHAR(191) PRIMARY KEY,
user_id VARCHAR(191),
type VARCHAR(191) NOT NULL,
severity VARCHAR(32) NOT NULL,
actor_user_id VARCHAR(191),
session_id VARCHAR(191),
ip VARCHAR(64),
user_agent TEXT,
created_at BIGINT NOT NULL,
data_json TEXT
);
CREATE INDEX IF NOT EXISTS wrn_auth_security_events_user_idx ON wrn_auth_security_events(user_id, created_at);
-- +down
DROP TABLE IF EXISTS wrn_auth_security_events;
DROP TABLE IF EXISTS wrn_auth_login_attempts;
DROP TABLE IF EXISTS wrn_auth_oauth_accounts;
DROP TABLE IF EXISTS wrn_auth_passkeys;
DROP TABLE IF EXISTS wrn_auth_recovery_codes;
DROP TABLE IF EXISTS wrn_auth_totp_credentials;
DROP TABLE IF EXISTS wrn_auth_otp_challenges;
DROP TABLE IF EXISTS wrn_auth_tokens;
DROP TABLE IF EXISTS wrn_auth_trusted_devices;
DROP TABLE IF EXISTS wrn_auth_sessions;
DROP TABLE IF EXISTS wrn_auth_password_credentials;
DROP TABLE IF EXISTS wrn_auth_identities;
DROP TABLE IF EXISTS wrn_auth_users;
@@ -0,0 +1,9 @@
-- +up
-- Separate challenge purposes prevent a verification OTP from being replayed
-- as a passwordless-login or MFA challenge.
ALTER TABLE wrn_auth_otp_challenges
ADD COLUMN purpose VARCHAR(32) NOT NULL DEFAULT 'verification';
-- +down
ALTER TABLE wrn_auth_otp_challenges
DROP COLUMN purpose;
+65
View File
@@ -0,0 +1,65 @@
{
"name": "@wrnexus/auth",
"version": "0.5.0",
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
"type": "module",
"sideEffects": false,
"main": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"src",
"components",
"assets",
"migrations",
"README.md",
"SECURITY.md",
"CHANGELOG.md"
],
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./client": "./src/client/index.ts",
"./plugin": "./src/plugin.ts",
"./types": "./src/types.ts",
"./store": "./src/store.ts",
"./stores/memory": "./src/stores/memory.ts",
"./engine": "./src/engine.ts",
"./middleware": "./src/middleware.ts",
"./http": "./src/http/index.ts",
"./totp": "./src/totp/index.ts",
"./passkeys": "./src/passkeys/index.ts",
"./components/*": "./components/*",
"./stores/sql": "./src/stores/sql.ts",
"./protector": "./src/protector.ts",
"./runtime": "./src/runtime.ts"
},
"scripts": {
"test": "bun test",
"typecheck": "tsc --noEmit",
"check": "bun run typecheck && bun run test"
},
"dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/captcha": "workspace:*",
"@wrnexus/core": "workspace:*",
"@wrnexus/encryption": "workspace:*",
"@wrnexus/jwt": "workspace:*",
"@wrnexus/oauth": "workspace:*",
"@wrnexus/plugin": "workspace:*",
"@wrnexus/queue": "workspace:*",
"@wrnexus/validation": "workspace:*",
"@wrnexus/db": "workspace:*"
},
"devDependencies": {
"@types/bun": "latest",
"@wrnexus/syntax": "workspace:*",
"typescript": "^5.9.2"
},
"wrnexus": {
"plugin": {
"plugin": "./src/plugin.ts",
"export": "default",
"factory": true
}
}
}
+16
View File
@@ -0,0 +1,16 @@
export interface AuthRuntimeApi {
mount(root?: ParentNode): void;
unmount(root?: ParentNode): void;
registerPasskey(element: HTMLElement): Promise<void>;
authenticatePasskey(element: HTMLElement): Promise<void>;
}
declare global {
interface Window {
WRNexusAuth?: AuthRuntimeApi;
}
}
export function authRuntime(): AuthRuntimeApi | undefined {
return typeof window === "undefined" ? undefined : window.WRNexusAuth;
}
+96
View File
@@ -0,0 +1,96 @@
import { hmacSign, sha256 } from "@wrnexus/encryption";
const encoder = new TextEncoder();
const MAX_RANDOM_ROUNDS = 128;
export function bytesToBase64Url(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
export function base64UrlToBytes(value: string): Uint8Array {
if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
throw new TypeError("Invalid base64url value");
}
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
let binary: string;
try {
binary = atob(padded);
} catch {
throw new TypeError("Invalid base64url value");
}
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
if (bytesToBase64Url(bytes) !== value) throw new TypeError("Invalid base64url value");
return bytes;
}
export function randomToken(random: (length: number) => Uint8Array, bytes = 32): string {
if (!Number.isInteger(bytes) || bytes <= 0) {
throw new RangeError("random token byte length must be a positive integer");
}
const value = random(bytes);
if (!(value instanceof Uint8Array) || value.length !== bytes) {
throw new TypeError(`random byte provider must return exactly ${bytes} bytes`);
}
return bytesToBase64Url(value);
}
export function randomDigits(random: (length: number) => Uint8Array, length = 6): string {
return randomFromAlphabet(random, "0123456789", length);
}
export function randomReadableCode(random: (length: number) => Uint8Array, length = 10): string {
return randomFromAlphabet(random, "ABCDEFGHJKLMNPQRSTUVWXYZ23456789", length);
}
function randomFromAlphabet(
random: (length: number) => Uint8Array,
alphabet: string,
length: number,
): string {
if (!Number.isInteger(length) || length < 0) throw new RangeError("length must be non-negative");
if (!alphabet.length || alphabet.length > 256 || new Set(alphabet).size !== alphabet.length) {
throw new TypeError("alphabet must contain 1 to 256 unique characters");
}
if (length === 0) return "";
const limit = Math.floor(256 / alphabet.length) * alphabet.length;
let output = "";
for (let round = 0; output.length < length && round < MAX_RANDOM_ROUNDS; round += 1) {
const requested = Math.max(16, (length - output.length) * 2);
const bytes = random(requested);
if (!(bytes instanceof Uint8Array) || bytes.length !== requested) {
throw new TypeError(`random byte provider must return exactly ${requested} bytes`);
}
for (const byte of bytes) {
if (byte >= limit) continue;
output += alphabet[byte % alphabet.length];
if (output.length === length) break;
}
}
if (output.length !== length) {
throw new Error("WRN-AUTH-RANDOM-SOURCE-REJECTED");
}
return output;
}
export async function hashSecret(value: string, secret: string): Promise<string> {
return hmacSign(value, secret);
}
export async function fingerprint(value: string): Promise<string> {
return sha256(value);
}
export async function constantTimeEqual(left: string, right: string): Promise<boolean> {
const leftBytes = encoder.encode(left);
const rightBytes = encoder.encode(right);
const length = Math.max(leftBytes.length, rightBytes.length);
let diff = leftBytes.length ^ rightBytes.length;
for (let index = 0; index < length; index += 1) {
diff |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0);
}
return diff === 0;
}
File diff suppressed because it is too large Load Diff
+525
View File
@@ -0,0 +1,525 @@
import type { Context } from "@wrnexus/core";
import { invalid, parseBody, type ObjectSchema } from "@wrnexus/validation";
import type { AuthEngine } from "../engine.ts";
import { safeAuthReturnTo } from "../normalize.ts";
import {
clearAuthSession,
establishAuthSession,
getAuthSession,
getAuthUser,
} from "../middleware.ts";
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "../validation.ts";
import type { AuthSignedInHandler, AuthSignedOutHandler } from "../types.ts";
function text(value: unknown): string {
return typeof value === "string" ? value : value == null ? "" : String(value);
}
function boolean(value: unknown): boolean {
return value === true || value === "true" || value === "on" || value === "1";
}
function json(data: unknown, status = 200): Response {
return Response.json(data, {
status,
headers: { "cache-control": "no-store" },
});
}
function parseValues<T = Record<string, unknown>>(
schema: ObjectSchema,
input: Record<string, unknown>,
): { ok: true; value: T } | { ok: false; response: Response } {
const result = schema.parse(input);
if (!result.ok) return { ok: false, response: invalid(result.errors) };
return { ok: true, value: result.value as T };
}
export interface AuthPasskeyHttpOptions {
/** Relying-party ID accepted by the server. Defaults to the request hostname. */
rpId?: string;
/** Display name included in registration options. */
rpName?: string;
/** Exact WebAuthn origin accepted by the server. Defaults to the request origin. */
origin?: string;
}
export interface AuthHttpOptions {
engine: AuthEngine;
baseUrl?: string;
schemas?: AuthSchemaOverrides | AuthSchemaSet;
passkey?: AuthPasskeyHttpOptions;
/** @deprecated Prefer createAuthEngine({ onSignedIn }). */
onSignedIn?: AuthSignedInHandler;
/** @deprecated Prefer createAuthEngine({ onSignedOut }). */
onSignedOut?: AuthSignedOutHandler;
}
export function createAuthHttpHandlers(options: AuthHttpOptions) {
const engine = options.engine;
const schemas = resolveAuthSchemas(options.schemas);
const onSignedIn = options.onSignedIn ?? engine.onSignedIn;
const onSignedOut = options.onSignedOut ?? engine.onSignedOut;
const onSuccessfulSignUp = engine.onSuccessfulSignUp;
function signupRedirect(ctx: Context, value: string | undefined, fallback: string): Response {
const path = safeAuthReturnTo(value, ctx.url.origin) ?? fallback;
return Response.redirect(new URL(path, ctx.url), 303);
}
function passkeyConfig(ctx: Context): { rpId: string; rpName: string; origin: string } {
const origin = options.passkey?.origin ?? ctx.url.origin;
let originHostname = ctx.url.hostname;
try {
originHostname = new URL(origin).hostname;
} catch {
// Configuration validation belongs to application startup; retain a safe request fallback.
}
return {
rpId: options.passkey?.rpId ?? originHostname,
rpName: options.passkey?.rpName ?? "WRNexusJS",
origin,
};
}
return {
async register(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.register, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.register({
email: text(input.email) || undefined,
phone: text(input.phone) || undefined,
username: text(input.username) || undefined,
password: text(input.password),
displayName: text(input.displayName) || undefined,
locale: text(input.locale) || undefined,
timezone: text(input.timezone) || undefined,
});
if (!result.ok || !result.user) return json(result, 400);
const action = await onSuccessfulSignUp?.(ctx, result.user);
if (action instanceof Response) return action;
if (action?.autoSignIn) {
const identifier = text(input.email) || text(input.phone) || text(input.username);
const loginResult = await engine.login({
identifier,
password: text(input.password),
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
captchaVerified:
Boolean((ctx.locals.captcha as { success?: boolean } | undefined)?.success) ||
ctx.locals.captchaVerified === true,
});
if (!loginResult.ok || !loginResult.session || !loginResult.user) {
return json(loginResult, 401);
}
establishAuthSession(ctx, loginResult.session, loginResult.user);
return signupRedirect(ctx, action.redirectTo, "/account");
}
return signupRedirect(ctx, action?.redirectTo, "/sign-in");
},
async login(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.login, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.login({
identifier: text(input.identifier),
password: text(input.password),
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
fingerprint: text(input.deviceFingerprint) || undefined,
deviceName: text(input.deviceName) || undefined,
rememberDevice: boolean(input.rememberDevice),
captchaVerified:
Boolean((ctx.locals.captcha as { success?: boolean } | undefined)?.success) ||
ctx.locals.captchaVerified === true,
signals: {
automationSuspected: Boolean(
ctx.locals.captchaRisk && (ctx.locals.captchaRisk as { challenge?: boolean }).challenge,
),
},
});
if (!result.ok || !result.session || !result.user) return json(result, 401);
establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) {
return onSignedIn(ctx, safeAuthReturnTo(text(input.returnTo) || undefined, ctx.url.origin));
}
return json(result);
},
async logout(ctx: Context): Promise<Response> {
const session = getAuthSession(ctx);
if (session) await engine.logout(session.id);
clearAuthSession(ctx);
return onSignedOut ? onSignedOut(ctx) : json({ ok: true });
},
async requestVerification(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.verificationRequest, ctx.req);
if (!validation.ok) return validation.response;
const type = text(validation.value.type) === "phone" ? "phone" : "email";
const current = getAuthUser(ctx);
const identifier = text(validation.value.identifier);
const user =
current ?? (identifier ? await engine.findUserByIdentifier(identifier) : undefined);
if (user) {
await engine.requestVerification(user.id, type, options.baseUrl ?? ctx.url.origin);
}
// Always return the same response to avoid revealing account existence.
return json({ ok: true });
},
async verifyEmail(ctx: Context): Promise<Response> {
const validation =
ctx.req.method === "GET"
? parseValues(schemas.verificationToken, {
token: ctx.url.searchParams.get("token"),
})
: await parseBody(schemas.verificationToken, ctx.req);
if (!validation.ok) return validation.response;
const result = await engine.verifyEmail(text(validation.value.token));
return json(result, result.ok ? 200 : 400);
},
async verifyPhone(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.verificationToken, ctx.req);
if (!validation.ok) return validation.response;
const result = await engine.verifyPhone(text(validation.value.token));
return json(result, result.ok ? 200 : 400);
},
async requestPasswordReset(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.passwordResetRequest, ctx.req);
if (!validation.ok) return validation.response;
await engine.requestPasswordReset(
text(validation.value.identifier),
options.baseUrl ?? ctx.url.origin,
);
return json({ ok: true });
},
async resetPassword(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.passwordReset, ctx.req);
if (!validation.ok) return validation.response;
const result = await engine.resetPassword(
text(validation.value.token),
text(validation.value.password),
);
return json(result, result.ok ? 200 : 400);
},
async acceptInvitation(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.invitationAccept, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.acceptInvitation(text(input.token), {
password: text(input.password) || undefined,
displayName: text(input.displayName) || undefined,
});
return json(result, result.ok ? 200 : 400);
},
async requestMagicLink(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.magicLinkRequest, ctx.req);
if (!validation.ok) return validation.response;
await engine.requestMagicLink(
text(validation.value.identifier),
options.baseUrl ?? ctx.url.origin,
);
return json({ ok: true });
},
async consumeMagicLink(ctx: Context): Promise<Response> {
const validation =
ctx.req.method === "GET"
? parseValues(schemas.magicLinkConsume, {
token: ctx.url.searchParams.get("token"),
returnTo: ctx.url.searchParams.get("returnTo") ?? undefined,
})
: await parseBody(schemas.magicLinkConsume, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.consumeMagicLink(text(input.token), {
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
});
if (result.code === "mfa-required") return json(result, 401);
if (!result.ok || !result.session || !result.user) return json(result, 400);
establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) {
return onSignedIn(ctx, safeAuthReturnTo(text(input.returnTo) || undefined, ctx.url.origin));
}
return json(result);
},
async requestOtpLogin(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.otpLoginRequest, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const method = text(input.method) === "sms-otp" ? "sms-otp" : "email-otp";
const challenge = await engine.requestOtpLogin(text(input.identifier), method);
return json({ ok: true, challenge: challenge ?? null });
},
async completeOtpLogin(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.otpLoginComplete, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.completeOtpLogin(text(input.challengeId), text(input.code), {
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
});
if (result.code === "mfa-required") return json(result, 401);
if (!result.ok || !result.session || !result.user) return json(result, 400);
establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) {
return onSignedIn(ctx, safeAuthReturnTo(text(input.returnTo) || undefined, ctx.url.origin));
}
return json(result);
},
async issueOtp(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.otpIssue, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const method = text(input.method) === "sms-otp" ? "sms-otp" : "email-otp";
try {
return json({
ok: true,
...(await engine.issueOtp(user.id, method, text(input.destination) || undefined)),
});
} catch (error) {
return json(
{
ok: false,
error: error instanceof Error ? error.message : "Unable to issue OTP",
},
400,
);
}
},
async verifyOtp(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.otpVerify, ctx.req);
if (!validation.ok) return validation.response;
const result = await engine.verifyOtp(
text(validation.value.challengeId),
text(validation.value.code),
"verification",
);
return json(result, result.ok ? 200 : 400);
},
async beginTotp(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.authenticatorSetup, ctx.req);
if (!validation.ok) return validation.response;
const setup = await engine.beginTotp(
user.id,
text(validation.value.label) || "Authenticator",
);
return json({ ok: true, ...setup });
},
async confirmTotp(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.authenticatorConfirm, ctx.req);
if (!validation.ok) return validation.response;
const ok = await engine.confirmTotp(
user.id,
text(validation.value.credentialId),
text(validation.value.code),
);
return ok
? json({ ok: true })
: json({ ok: false, error: "Authenticator code is invalid" }, 400);
},
async disableTotp(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.authenticatorDisable, ctx.req);
if (!validation.ok) return validation.response;
const ok = await engine.disableTotp(user.id, text(validation.value.credentialId));
return ok
? json({ ok: true })
: json({ ok: false, error: "Authenticator credential was not found" }, 404);
},
async recoveryCodes(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.recoveryCodes, ctx.req);
if (!validation.ok) return validation.response;
const count = typeof validation.value.count === "number" ? validation.value.count : undefined;
const codes = await engine.generateRecoveryCodes(user.id, count);
return json({ ok: true, codes });
},
async changePassword(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.changePassword, ctx.req);
if (!validation.ok) return validation.response;
const result = await engine.changePassword(
user.id,
text(validation.value.currentPassword),
text(validation.value.nextPassword),
);
if (result.ok) clearAuthSession(ctx);
return json(result, result.ok ? 200 : 400);
},
async beginMfaOtp(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.mfaOtpRequest, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const method = text(input.method) === "sms-otp" ? "sms-otp" : "email-otp";
const challenge = await engine.beginMfaOtp(text(input.mfaToken), method);
return challenge
? json({ ok: true, ...challenge })
: json({ ok: false, error: "MFA transaction is invalid or expired" }, 400);
},
async completeMfa(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.mfaComplete, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const methodValue = text(input.method);
const method = ["totp", "recovery-code", "email-otp", "sms-otp"].includes(methodValue)
? (methodValue as "totp" | "recovery-code" | "email-otp" | "sms-otp")
: "totp";
const result = await engine.completeMfa({
mfaToken: text(input.mfaToken),
method,
code: text(input.code),
challengeId: text(input.challengeId) || undefined,
session: {
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
},
});
if (result.code === "mfa-required") return json(result, 401);
if (!result.ok || !result.session || !result.user) return json(result, 400);
establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) {
return onSignedIn(ctx, safeAuthReturnTo(text(input.returnTo) || undefined, ctx.url.origin));
}
return json(result);
},
async startImpersonation(ctx: Context): Promise<Response> {
const actor = getAuthUser(ctx);
const current = getAuthSession(ctx);
if (!actor) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.impersonationStart, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.startImpersonation(actor.id, text(input.targetUserId), {
reason: text(input.reason) || undefined,
sessionId: current?.id,
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
});
if (!result.ok || !result.session || !result.user) return json(result, 403);
establishAuthSession(ctx, result.session, result.user);
return json(result);
},
async stopImpersonation(ctx: Context): Promise<Response> {
const current = getAuthSession(ctx);
if (!current) return json({ ok: false, error: "Unauthorized" }, 401);
const result = await engine.stopImpersonation(current.id);
if (result.code === "mfa-required") return json(result, 401);
if (!result.ok || !result.session || !result.user) return json(result, 400);
establishAuthSession(ctx, result.session, result.user);
return json(result);
},
async sessions(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
return json({
ok: true,
sessions: await engine.listSessions(user.id),
currentSessionId: getAuthSession(ctx)?.id,
});
},
async revokeSession(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.sessionRevoke, ctx.req);
if (!validation.ok) return validation.response;
return json({
ok: await engine.revokeSession(user.id, text(validation.value.sessionId)),
});
},
async passkeyRegistrationOptions(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.passkeyRegistrationOptions, ctx.req);
if (!validation.ok) return validation.response;
const config = passkeyConfig(ctx);
return json({
ok: true,
...(await engine.beginPasskeyRegistration(user.id, config)),
});
},
async passkeyRegistrationVerify(ctx: Context): Promise<Response> {
const user = getAuthUser(ctx);
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
const validation = await parseBody(schemas.passkeyRegistrationVerify, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const ok = await engine.finishPasskeyRegistration(user.id, {
key: text(input.key),
response: input.response,
name: text(input.name) || undefined,
});
return json({ ok }, ok ? 200 : 400);
},
async passkeyAuthenticationOptions(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.passkeyAuthenticationOptions, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const config = passkeyConfig(ctx);
return json({
ok: true,
...(await engine.beginPasskeyAuthentication({
identifier: text(input.identifier) || undefined,
rpId: config.rpId,
origin: config.origin,
})),
});
},
async passkeyAuthenticationVerify(ctx: Context): Promise<Response> {
const validation = await parseBody(schemas.passkeyAuthenticationVerify, ctx.req);
if (!validation.ok) return validation.response;
const input = validation.value;
const result = await engine.finishPasskeyAuthentication({
key: text(input.key),
response: input.response,
session: {
ip: ctx.ip,
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
},
});
if (result.code === "mfa-required") return json(result, 401);
if (!result.ok || !result.session || !result.user) return json(result, 400);
establishAuthSession(ctx, result.session, result.user);
if (onSignedIn) return onSignedIn(ctx);
return json(result);
},
};
}
+103
View File
@@ -0,0 +1,103 @@
export { createAuthEngine, type AuthEngine } from "./engine.ts";
export type { AuthStore } from "./store.ts";
export { MemoryAuthStore } from "./stores/memory.ts";
export { SqlAuthStore } from "./stores/sql.ts";
export {
authSession,
requireAuth,
establishAuthSession,
clearAuthSession,
getAuthUser,
getAuthSession,
isAuthenticatedContext,
AUTH_SESSION_KEY,
} from "./middleware.ts";
export {
createAuthHttpHandlers,
type AuthHttpOptions,
type AuthPasskeyHttpOptions,
} from "./http/index.ts";
export {
authPlugin,
authComponentsDir,
type AuthConfig,
type AuthRoutesConfig,
type AuthPluginOptions,
type AuthAuditIssue,
} from "./plugin.ts";
export {
setDefaultAuthEngine,
clearDefaultAuthEngine,
setDefaultAuthSchemas,
setDefaultAuthRouteOptions,
tryGetDefaultAuthEngine,
getDefaultAuthEngine,
getDefaultAuthSchemas,
getDefaultAuthRouteOptions,
hasDefaultAuthEngine,
type DefaultAuthRouteOptions,
} from "./runtime.ts";
export { createAuthSecretProtector } from "./protector.ts";
export { evaluateAuthRisk, type RiskPolicy } from "./risk.ts";
export {
normalizeEmail,
normalizePhone,
normalizeUsername,
normalizeIdentity,
inferIdentityType,
publicUser,
safeAuthReturnTo,
} from "./normalize.ts";
export {
generateTotpSecret,
generateTotp,
verifyTotp,
totpUri,
encodeBase32,
decodeBase32,
} from "./totp/index.ts";
export {
MemoryPasskeyChallengeStore,
assertPasskeyProvider,
type PasskeyChallengeStore,
type PasskeyChallengeRecord,
type PasskeyChallengeKind,
} from "./passkeys/index.ts";
export * from "./types.ts";
export {
registerSchema,
signUpSchema,
loginSchema,
verificationRequestSchema,
verificationTokenSchema,
passwordResetRequestSchema,
passwordResetSchema,
invitationAcceptSchema,
magicLinkRequestSchema,
magicLinkConsumeSchema,
otpLoginRequestSchema,
otpLoginCompleteSchema,
otpIssueSchema,
otpSchema,
mfaOtpRequestSchema,
mfaSchema,
sessionRevokeSchema,
impersonationStartSchema,
passkeyRegistrationOptionsSchema,
passkeyRegistrationVerifySchema,
passkeyAuthenticationOptionsSchema,
passkeyAuthenticationVerifySchema,
authenticatorSetupSchema,
authenticatorConfirmSchema,
authenticatorDisableSchema,
recoveryCodesSchema,
emptyActionSchema,
changePasswordSchema,
authSchemas,
authBrowserSchemaMap,
authBrowserSchemaDescriptors,
resolveAuthSchemas,
type AuthSchemaSet,
type AuthSchemaOverrides,
} from "./validation.ts";
+109
View File
@@ -0,0 +1,109 @@
import type { Context, Middleware } from "@wrnexus/core";
import type { AuthEngine } from "./engine.ts";
import { publicUser } from "./normalize.ts";
import type { AuthenticatedContext, AuthPublicUser, AuthSession } from "./types.ts";
export const AUTH_SESSION_KEY = "wrnexus.auth.session";
function wantsJson(ctx: Context): boolean {
if (ctx.url.pathname.startsWith("/api/") || ctx.url.pathname.startsWith("/__wrnexus/"))
return true;
const accept = ctx.req.headers.get("accept") ?? "";
return accept.includes("application/json") && !accept.includes("text/html");
}
export function authSession(engine: AuthEngine): Middleware {
return async (ctx, next) => {
const sessionId = ctx.session.get<string>(AUTH_SESSION_KEY);
if (!sessionId) {
ctx.user = null;
ctx.locals.authUser = null;
delete ctx.locals.authSession;
return next();
}
const session = await engine.validateSession(sessionId);
if (!session) {
ctx.session.delete(AUTH_SESSION_KEY);
ctx.user = null;
ctx.locals.authUser = null;
delete ctx.locals.authSession;
return next();
}
const user = await engine.getUser(session.userId);
if (!user || user.status !== "active") {
ctx.session.delete(AUTH_SESSION_KEY);
ctx.user = null;
ctx.locals.authUser = null;
delete ctx.locals.authSession;
return next();
}
const safe = publicUser(user);
ctx.user = safe;
ctx.locals.authUser = safe;
ctx.locals.authSession = session;
return next();
};
}
export function establishAuthSession(
ctx: Context,
session: AuthSession,
user: AuthPublicUser,
): void {
ctx.session.regenerate();
ctx.session.set(AUTH_SESSION_KEY, session.id);
ctx.user = user;
ctx.locals.authUser = user;
ctx.locals.authSession = session;
}
export function clearAuthSession(ctx: Context): void {
ctx.session.delete(AUTH_SESSION_KEY);
ctx.user = null;
delete ctx.locals.authUser;
delete ctx.locals.authSession;
}
export function getAuthUser(ctx: Context): AuthPublicUser | null {
return (
(ctx.locals.authUser as AuthPublicUser | null | undefined) ??
(ctx.user as AuthPublicUser | null | undefined) ??
null
);
}
export function getAuthSession(ctx: Context): AuthSession | null {
return (ctx.locals.authSession as AuthSession | undefined) ?? null;
}
export interface RequireAuthOptions {
loginPath?: string;
returnToParam?: string;
roles?: string[];
status?: AuthPublicUser["status"][];
}
export function requireAuth(options: RequireAuthOptions = {}): Middleware {
const loginPath = options.loginPath ?? "/sign-in";
const returnToParam = options.returnToParam ?? "returnTo";
return (ctx, next) => {
const user = getAuthUser(ctx);
const allowedStatus = options.status ?? ["active"];
const allowedRole =
!options.roles?.length || options.roles.some((role) => user?.roles.includes(role));
if (user && allowedStatus.includes(user.status) && allowedRole) return next();
if (wantsJson(ctx)) {
return Response.json(
{ ok: false, error: user ? "Forbidden" : "Unauthorized" },
{ status: user ? 403 : 401 },
);
}
const redirect = new URL(loginPath, ctx.url);
redirect.searchParams.set(returnToParam, `${ctx.url.pathname}${ctx.url.search}`);
return Response.redirect(redirect, 302);
};
}
export function isAuthenticatedContext(ctx: Context): ctx is AuthenticatedContext {
return Boolean(getAuthUser(ctx));
}
+55
View File
@@ -0,0 +1,55 @@
import type { AuthIdentityType, AuthPublicUser, AuthUser } from "./types.ts";
export function normalizeEmail(value: string): string {
return value.trim().toLowerCase();
}
export function normalizeUsername(value: string): string {
return value.trim().toLowerCase();
}
export function normalizePhone(value: string): string {
const trimmed = value.trim();
const prefix = trimmed.startsWith("+") ? "+" : "";
return prefix + trimmed.replace(/\D/g, "");
}
export function normalizeIdentity(type: AuthIdentityType, value: string): string {
if (type === "email") return normalizeEmail(value);
if (type === "phone") return normalizePhone(value);
return normalizeUsername(value);
}
export function inferIdentityType(value: string): AuthIdentityType {
const input = value.trim();
if (input.includes("@")) return "email";
if (/^\+?[0-9 ()-]{7,}$/.test(input)) return "phone";
return "username";
}
export function publicUser(user: AuthUser): AuthPublicUser {
return {
id: user.id,
username: user.username,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
status: user.status,
roles: [...user.roles],
emailVerified: user.emailVerified,
phoneVerified: user.phoneVerified,
mfaEnabled: user.mfaEnabled,
};
}
/** Return a same-origin path for post-authentication navigation. */
export function safeAuthReturnTo(value: string | undefined, origin: string): string | undefined {
if (!value) return undefined;
try {
const base = new URL(origin);
const target = new URL(value, base);
if (target.origin !== base.origin) return undefined;
return `${target.pathname}${target.search}${target.hash}`;
} catch {
return undefined;
}
}
+100
View File
@@ -0,0 +1,100 @@
import type {
PasskeyAuthenticationOptions,
PasskeyProvider,
PasskeyRegistrationOptions,
} from "../types.ts";
export type { PasskeyProvider } from "../types.ts";
export type PasskeyChallengeKind = "registration" | "authentication";
export interface PasskeyChallengeRecord {
challenge: string;
kind: PasskeyChallengeKind;
userId?: string;
rpId: string;
origin: string;
expiresAt: number;
}
/**
* Durable challenge storage contract. Production deployments with more than one
* process should provide a shared implementation (for example Redis or SQL).
*/
export interface PasskeyChallengeStore {
set(key: string, value: PasskeyChallengeRecord): Promise<void>;
consume(key: string): Promise<PasskeyChallengeRecord | undefined>;
}
export class MemoryPasskeyChallengeStore implements PasskeyChallengeStore {
private readonly values = new Map<string, PasskeyChallengeRecord>();
private readonly now: () => number;
constructor(now: () => number = () => Date.now()) {
this.now = now;
}
private pruneExpired(): void {
const timestamp = this.now();
for (const [key, value] of this.values) {
if (value.expiresAt <= timestamp) this.values.delete(key);
}
}
async set(key: string, value: PasskeyChallengeRecord): Promise<void> {
this.pruneExpired();
this.values.set(key, { ...value });
}
async consume(key: string): Promise<PasskeyChallengeRecord | undefined> {
this.pruneExpired();
const value = this.values.get(key);
this.values.delete(key);
return value ? { ...value } : undefined;
}
}
export function assertPasskeyProvider(provider: PasskeyProvider | undefined): PasskeyProvider {
if (!provider) throw new Error("WRN-AUTH-PASSKEY-PROVIDER: configure a PasskeyProvider");
return provider;
}
export function publicKeyCreationOptions(
options: PasskeyRegistrationOptions,
): PublicKeyCredentialCreationOptions {
return {
...options,
challenge: decode(options.challenge),
user: { ...options.user, id: decode(options.user.id) },
excludeCredentials: options.excludeCredentials?.map((item) => ({
...item,
id: decode(item.id),
transports: item.transports as AuthenticatorTransport[] | undefined,
})),
} as unknown as PublicKeyCredentialCreationOptions;
}
export function publicKeyRequestOptions(
options: PasskeyAuthenticationOptions,
): PublicKeyCredentialRequestOptions {
return {
...options,
challenge: decode(options.challenge),
allowCredentials: options.allowCredentials?.map((item) => ({
...item,
id: decode(item.id),
transports: item.transports as AuthenticatorTransport[] | undefined,
})),
} as unknown as PublicKeyCredentialRequestOptions;
}
function decode(value: string): Uint8Array {
if (!value || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) {
throw new TypeError("Passkey data must be canonical Base64URL without padding");
}
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(normalized + "=".repeat((4 - (normalized.length % 4)) % 4));
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
}
+414
View File
@@ -0,0 +1,414 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
import type { AuthEngine } from "./engine.ts";
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
import type { AuthSignedInHandler, AuthSignedOutHandler } from "./types.ts";
import { AUTH_ROUTE_DEFINITIONS, type AuthRouteGroup } from "./routes/definitions.ts";
import {
clearDefaultAuthEngine,
setDefaultAuthEngine,
setDefaultAuthRouteOptions,
setDefaultAuthSchemas,
} from "./runtime.ts";
import {
authBrowserSchemaDescriptors,
resolveAuthSchemas,
type AuthSchemaOverrides,
type AuthSchemaSet,
} from "./validation.ts";
export interface AuthRoutesConfig {
enabled?: boolean;
registration?: boolean;
login?: boolean;
verification?: boolean;
password?: boolean;
invitations?: boolean;
magicLink?: boolean;
otp?: boolean;
mfa?: boolean;
sessions?: boolean;
impersonation?: boolean;
passkeys?: boolean;
}
export interface AuthConfig {
enabled?: boolean;
engine?: AuthEngine;
routes?: boolean | AuthRoutesConfig;
migrations?: boolean;
middleware?: boolean;
components?: boolean;
client?: boolean;
devToolbar?: boolean;
componentDir?: string;
schemas?: AuthSchemaOverrides;
baseUrl?: string;
csrf?: boolean;
passkey?: AuthPasskeyHttpOptions;
/** @deprecated Prefer createAuthEngine({ onSignedIn }). */
onSignedIn?: AuthSignedInHandler;
/** @deprecated Prefer createAuthEngine({ onSignedOut }). */
onSignedOut?: AuthSignedOutHandler;
}
/** Explicit plugin options remain supported for compatibility. Prefer config.auth. */
export interface AuthPluginOptions {
componentDir?: string;
exposeComponentDirectory?: boolean;
enableDevToolbar?: boolean;
includeMigrations?: boolean;
includeRoutes?: boolean;
includeMiddleware?: boolean;
}
export interface AuthAuditIssue {
id: string;
severity: "error" | "warning" | "suggestion";
title: string;
message: string;
file: string;
}
interface ResolvedAuthConfig {
enabled: boolean;
engine?: AuthEngine;
routes: boolean | AuthRoutesConfig;
migrations: boolean;
middleware: boolean;
components: boolean;
client: boolean;
devToolbar: boolean;
componentDir: string;
schemas: AuthSchemaSet;
baseUrl?: string;
csrf: boolean;
passkey?: AuthPasskeyHttpOptions;
onSignedIn?: AuthSignedInHandler;
onSignedOut?: AuthSignedOutHandler;
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const clientRuntime = join(packageRoot, "assets", "client", "auth.js");
const migrationsFile = join(packageRoot, "migrations", "001_auth.sql");
const otpPurposeMigrationFile = join(packageRoot, "migrations", "002_auth_otp_purpose.sql");
const apiRoutesDir = join(packageRoot, "src", "routes", "api");
const middlewareFile = join(packageRoot, "src", "routes", "middleware.ts");
const resolvedConfigKey = "@wrnexus/auth:resolved-config";
export function authComponentsDir(): string {
return join(packageRoot, "components");
}
function authApiRouteEntry(path: string): string {
const routeName = path.replace(/^\/api\/auth\/?/, "").replace(/\//g, "-") || "index";
return join(apiRoutesDir, routeName + ".ts");
}
function resolveConfig(
config: Record<string, unknown>,
options: AuthPluginOptions,
): ResolvedAuthConfig {
const raw = (config.auth ?? {}) as AuthConfig;
const enabled = raw.enabled !== false;
const hasEngine = Boolean(raw.engine);
const hasDefaultDb = Boolean(config.db);
return {
enabled,
engine: raw.engine,
routes: options.includeRoutes !== undefined ? options.includeRoutes : (raw.routes ?? hasEngine),
migrations:
options.includeMigrations !== undefined
? options.includeMigrations
: (raw.migrations ?? (hasEngine && hasDefaultDb)),
middleware:
options.includeMiddleware !== undefined
? options.includeMiddleware
: (raw.middleware ?? hasEngine),
components:
options.exposeComponentDirectory !== undefined
? options.exposeComponentDirectory
: (raw.components ?? true),
client: raw.client ?? true,
devToolbar:
options.enableDevToolbar !== undefined ? options.enableDevToolbar : (raw.devToolbar ?? true),
componentDir: options.componentDir ?? raw.componentDir ?? authComponentsDir(),
schemas: resolveAuthSchemas(raw.schemas),
baseUrl: raw.baseUrl,
csrf: raw.csrf ?? true,
passkey: raw.passkey,
onSignedIn: raw.onSignedIn ?? raw.engine?.onSignedIn,
onSignedOut: raw.onSignedOut ?? raw.engine?.onSignedOut,
};
}
function fallbackConfig(options: AuthPluginOptions): ResolvedAuthConfig {
return {
enabled: true,
// Automatic package discovery must never expose authentication
// endpoints unless the application configures auth or the developer
// explicitly enables the routes through plugin options.
routes: options.includeRoutes ?? false,
migrations: options.includeMigrations ?? false,
middleware: options.includeMiddleware ?? false,
// Components and browser assets are safe to expose automatically.
components: options.exposeComponentDirectory ?? true,
client: true,
devToolbar: options.enableDevToolbar ?? true,
componentDir: options.componentDir ?? authComponentsDir(),
schemas: resolveAuthSchemas(),
csrf: true,
};
}
function resolved(context: PluginContext, options: AuthPluginOptions): ResolvedAuthConfig {
return (
(context.metadata.get(resolvedConfigKey) as ResolvedAuthConfig | undefined) ??
fallbackConfig(options)
);
}
function routeEnabled(routes: boolean | AuthRoutesConfig, group: AuthRouteGroup): boolean {
if (typeof routes === "boolean") return routes;
if (routes.enabled === false) return false;
return routes[group] !== false;
}
function authClientSource(schemas: AuthSchemaSet): string {
const descriptors = JSON.stringify(authBrowserSchemaDescriptors(schemas));
const runtime = readFileSync(clientRuntime, "utf8");
return `
(function () {
var defaults = ${descriptors};
window.__wireSchemas =
window.__wireSchemas || {};
Object.keys(defaults).forEach(
function (name) {
if (!(name in window.__wireSchemas)) {
window.__wireSchemas[name] =
defaults[name];
}
}
);
if (
window.__wireValidate &&
typeof window.__wireValidate
.registerSchemas === "function"
) {
window.__wireValidate.registerSchemas(
defaults,
document
);
} else if (
window.__wireValidate &&
typeof window.__wireValidate.init ===
"function"
) {
window.__wireValidate.init(document);
}
})();
${runtime}
`;
}
function audit(code: string, file: string): AuthAuditIssue[] {
const issues: AuthAuditIssue[] = [];
const push = (id: string, severity: AuthAuditIssue["severity"], title: string, message: string) =>
issues.push({ id: `${id}:${file}`, severity, title, message, file });
if (/<SignIn|<SignUp|data-component=["']SignIn|data-component=["']SignUp/.test(code)) {
if (!/<Captcha|captchaGuard|captchaPageGate/.test(code)) {
push(
"captcha-escalation",
"suggestion",
"Add adaptive CAPTCHA",
"Authentication forms should connect suspicious attempts to @wrnexus/captcha.",
);
}
if (!/autocomplete=/.test(code)) {
push(
"autocomplete",
"warning",
"Credential autocomplete is missing",
"Use username, current-password, and new-password autocomplete values.",
);
}
}
if (/secret\s*=|clientSecret\s*=|privateKey\s*=/.test(code) && /\.wrn$/.test(file)) {
push(
"client-secret",
"error",
"Authentication secret exposed",
"Never pass server secrets to a .wrn component.",
);
}
if (
/returnTo|redirect/.test(code) &&
!/validateOAuthReturnTo|safeReturnTo|startsWith\(["']\//.test(code)
) {
push(
"open-redirect",
"suggestion",
"Confirm redirects are same-origin",
"Validate returnTo values before redirecting after sign-in.",
);
}
return issues;
}
export function authPlugin(options: AuthPluginOptions = {}) {
const metadataKey = "@wrnexus/auth:audit";
return definePlugin({
name: "@wrnexus/auth",
version: "0.5.0",
enforce: "post",
componentDirs(context) {
const value = resolved(context, options);
return value.enabled && value.components ? [value.componentDir] : [];
},
clientRuntimes(context) {
const value = resolved(context, options);
if (!value.enabled || !value.client) return [];
return [
{
id: "auth",
source: authClientSource(value.schemas),
type: "script" as const,
load: "defer" as const,
singleton: true,
bundle: false,
},
];
},
styleSources(context) {
const value = resolved(context, options);
return value.enabled && value.components
? [{ id: "auth-components", source: value.componentDir, order: "normal" as const }]
: [];
},
routeEntries(context) {
const value = resolved(context, options);
if (!value.enabled) {
return [];
}
return AUTH_ROUTE_DEFINITIONS.filter((route) => routeEnabled(value.routes, route.group)).map(
({ path }) => ({
kind: "api" as const,
path,
entry: authApiRouteEntry(path),
}),
);
},
middleware(context) {
const value = resolved(context, options);
return value.enabled && value.middleware ? [middlewareFile] : [];
},
migrations(context) {
const value = resolved(context, options);
return value.enabled && value.migrations
? [
{
id: "wrnexus-auth-001",
source: readFileSync(migrationsFile, "utf8"),
},
{
id: "wrnexus-auth-002-otp-purpose",
source: readFileSync(otpPurposeMigrationFile, "utf8"),
},
]
: [];
},
configure(config, context) {
const value = resolveConfig(config, options);
context.metadata.set(resolvedConfigKey, value);
config.auth = {
...((config.auth ?? {}) as Record<string, unknown>),
componentDir: value.componentDir,
schemas: value.schemas,
};
if (value.engine) {
setDefaultAuthEngine(value.engine);
} else {
clearDefaultAuthEngine();
}
setDefaultAuthSchemas(value.schemas);
setDefaultAuthRouteOptions({
baseUrl: value.baseUrl,
csrf: value.csrf,
passkey: value.passkey,
onSignedIn: value.onSignedIn,
onSignedOut: value.onSignedOut,
});
context.metadata.set("@wrnexus/auth:component-dir", value.componentDir);
context.metadata.set("@wrnexus/auth:configured", Boolean(value.engine));
},
transformCode(code, context) {
if (context.mode !== "development") return;
const previous = (context.metadata.get(metadataKey) as AuthAuditIssue[] | undefined) ?? [];
context.metadata.set(metadataKey, [
...previous.filter((issue) => issue.file !== context.file),
...audit(code, context.file),
]);
},
devToolbarPanels(context) {
const value = resolved(context, options);
if (!value.enabled || !value.devToolbar) return [];
const issues = (context.metadata.get(metadataKey) as AuthAuditIssue[] | undefined) ?? [];
return [
{
id: "wrnexus-auth",
title: "Authentication",
icon: "shield-user",
badge: issues.length,
description: "Authentication security, session, passkey, and recovery checks",
issues,
},
];
},
});
}
export default authPlugin;
+39
View File
@@ -0,0 +1,39 @@
import { open, seal, type EncryptionKeyring } from "@wrnexus/encryption";
import type { AuthSecretProtector } from "./types.ts";
const PURPOSE_PREFIX = "wrn-auth-secret:v1:";
type AuthSecretPurpose = "totp" | "oauth-access" | "oauth-refresh";
function bindPurpose(value: string, purpose: AuthSecretPurpose): string {
return `${PURPOSE_PREFIX}${purpose}\0${value}`;
}
function revealBoundValue(value: string, purpose: AuthSecretPurpose): string {
if (!value.startsWith(PURPOSE_PREFIX)) {
// Backward compatibility for ciphertext written before purpose binding was introduced.
return value;
}
const separator = value.indexOf("\0", PURPOSE_PREFIX.length);
if (separator < 0) throw new Error("WRN-AUTH-SECRET-PAYLOAD");
const storedPurpose = value.slice(PURPOSE_PREFIX.length, separator);
if (storedPurpose !== purpose) throw new Error("WRN-AUTH-SECRET-PURPOSE");
return value.slice(separator + 1);
}
/**
* Protect TOTP and OAuth secrets with the versioned @wrnexus/encryption keyring.
* Rotated keys continue to decrypt old records while new writes use the active key.
* New payloads are bound to their purpose so encrypted values cannot be swapped
* between TOTP, OAuth access-token, and OAuth refresh-token fields.
*/
export function createAuthSecretProtector(keyring: EncryptionKeyring): AuthSecretProtector {
return {
async protect(value, purpose) {
return seal(bindPurpose(value, purpose), keyring);
},
async reveal(value, purpose) {
return revealBoundValue(await open(value, keyring), purpose);
},
};
}
+60
View File
@@ -0,0 +1,60 @@
import type { AuthRiskDecision, AuthRiskSignals, AuthRiskLevel } from "./types.ts";
export interface RiskPolicy {
captchaThreshold: number;
mfaThreshold: number;
blockThreshold: number;
}
function finiteScore(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function threshold(value: number, fallback: number): number {
return Math.min(100, Math.max(0, finiteScore(value, fallback)));
}
function level(score: number): AuthRiskLevel {
if (score >= 90) return "critical";
if (score >= 65) return "high";
if (score >= 35) return "medium";
return "low";
}
export function evaluateAuthRisk(
signals: AuthRiskSignals = {},
policy: RiskPolicy = { captchaThreshold: 35, mfaThreshold: 60, blockThreshold: 90 },
): AuthRiskDecision {
let score = Math.max(0, finiteScore(signals.customScore));
const reasons: string[] = [];
const add = (condition: boolean | undefined, points: number, reason: string) => {
if (!condition) return;
score += points;
reasons.push(reason);
};
const failedAttempts = Math.max(0, Math.floor(finiteScore(signals.failedAttempts)));
if (failedAttempts > 0) {
score += Math.min(45, failedAttempts * 10);
reasons.push("failed-attempts");
}
add(signals.unfamiliarDevice, 18, "unfamiliar-device");
add(signals.unusualIp, 20, "unusual-ip");
add(signals.impossibleTravel, 35, "impossible-travel");
add(signals.breachedPassword, 45, "breached-password");
add(signals.automationSuspected, 40, "automation-suspected");
add(signals.accountLocked, 100, "account-locked");
score = Math.min(100, Math.max(0, score));
const captchaThreshold = threshold(policy.captchaThreshold, 35);
const mfaThreshold = threshold(policy.mfaThreshold, 60);
const blockThreshold = threshold(policy.blockThreshold, 90);
return {
score,
level: level(score),
requireCaptcha: score >= captchaThreshold,
requireMfa: score >= mfaThreshold,
block: score >= blockThreshold,
reasons,
};
}
+125
View File
@@ -0,0 +1,125 @@
import { verifyCsrf, type Context } from "@wrnexus/core";
import { createAuthHttpHandlers } from "../http/index.ts";
import {
getDefaultAuthEngine,
getDefaultAuthRouteOptions,
getDefaultAuthSchemas,
hasDefaultAuthEngine,
} from "../runtime.ts";
export type AuthHttpHandlers = ReturnType<typeof createAuthHttpHandlers>;
import { AUTH_ROUTE_DEFINITIONS, type AuthHandlerName } from "./definitions.ts";
const ROUTES = Object.fromEntries(
AUTH_ROUTE_DEFINITIONS.map((definition) => [definition.path, definition]),
) as Readonly<Record<string, (typeof AUTH_ROUTE_DEFINITIONS)[number]>>;
function unavailable(): Response {
return Response.json(
{
ok: false,
error: "WRN-AUTH-NOT-CONFIGURED",
message: "Configure auth.engine before serving package auth routes.",
},
{ status: 503, headers: { "cache-control": "no-store" } },
);
}
function normalizeRoutePath(value: string): string {
try {
return (decodeURIComponent(value).replace(/\/+$/, "") || "/").toLowerCase();
} catch {
return (value.replace(/\/+$/, "") || "/").toLowerCase();
}
}
function requestRoutePath(ctx: Context): string {
const matchedRoute = ctx.locals.__wrnexusRoute;
if (typeof matchedRoute === "string" && matchedRoute.startsWith("/api/auth/")) {
return normalizeRoutePath(matchedRoute);
}
try {
return normalizeRoutePath(new URL(ctx.req.url).pathname);
} catch {
return normalizeRoutePath(ctx.url.pathname);
}
}
function methodNotAllowed(allowed: readonly string[]): Response {
return Response.json(
{ ok: false, error: "Method Not Allowed" },
{ status: 405, headers: { allow: allowed.join(", "), "cache-control": "no-store" } },
);
}
function handlersFor(ctx: Context): AuthHttpHandlers | undefined {
if (!hasDefaultAuthEngine()) return undefined;
const routeOptions = getDefaultAuthRouteOptions();
return createAuthHttpHandlers({
engine: getDefaultAuthEngine(),
schemas: getDefaultAuthSchemas(),
baseUrl: routeOptions.baseUrl ?? ctx.url.origin,
passkey: routeOptions.passkey,
onSignedIn: routeOptions.onSignedIn,
onSignedOut: routeOptions.onSignedOut,
});
}
/** Invoke one concrete handler. Route-specific entry modules use this path. */
export async function invokeAuthHandler(name: AuthHandlerName, ctx: Context): Promise<Response> {
const handlers = handlersFor(ctx);
if (!handlers) return unavailable();
const method = ctx.req.method.toUpperCase();
const unsafeMethod = method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
if (unsafeMethod && getDefaultAuthRouteOptions().csrf !== false && !verifyCsrf(ctx)) {
return Response.json(
{ ok: false, error: "Invalid CSRF token" },
{ status: 403, headers: { "cache-control": "no-store" } },
);
}
try {
return await handlers[name](ctx);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.startsWith("WRN-AUTH-PASSKEY-PROVIDER:")) {
return Response.json(
{
ok: false,
error: "Passkeys are unavailable",
code: "passkey-provider-not-configured",
},
{ status: 503, headers: { "cache-control": "no-store" } },
);
}
console.error(`[wrnexus:auth] ${name} failed`, error);
return Response.json(
{ ok: false, error: "Authentication request failed" },
{ status: 500, headers: { "cache-control": "no-store" } },
);
}
}
/** Backward-compatible dispatcher for applications importing the shared route. */
export async function dispatchAuthRoute(routePath: string, ctx: Context): Promise<Response> {
const definition = ROUTES[normalizeRoutePath(routePath)];
if (!definition) {
return Response.json(
{ ok: false, error: "Not Found" },
{ status: 404, headers: { "cache-control": "no-store" } },
);
}
const method = ctx.req.method.toUpperCase();
if (!definition.methods.some((allowed) => allowed === method)) {
return methodNotAllowed(definition.methods);
}
return invokeAuthHandler(definition.handler, ctx);
}
export default async function authApi(ctx: Context): Promise<Response> {
return dispatchAuthRoute(requestRoutePath(ctx), ctx);
}
export const GET = authApi;
export const POST = authApi;
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("startImpersonation", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("stopImpersonation", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("acceptInvitation", ctx);
}
+6
View File
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("login", ctx);
}
+6
View File
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("logout", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("requestMagicLink", ctx);
}
@@ -0,0 +1,10 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function GET(ctx: Context): Promise<Response> {
return invokeAuthHandler("consumeMagicLink", ctx);
}
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("consumeMagicLink", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("completeMfa", ctx);
}
+6
View File
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("beginMfaOtp", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("completeOtpLogin", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("requestOtpLogin", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("verifyOtp", ctx);
}
+6
View File
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("issueOtp", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("passkeyAuthenticationOptions", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("passkeyAuthenticationVerify", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("passkeyRegistrationOptions", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("passkeyRegistrationVerify", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("changePassword", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("requestPasswordReset", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("resetPassword", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("recoveryCodes", ctx);
}
+6
View File
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("register", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("revokeSession", ctx);
}
+6
View File
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function GET(ctx: Context): Promise<Response> {
return invokeAuthHandler("sessions", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("confirmTotp", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("disableTotp", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("beginTotp", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("requestVerification", ctx);
}
@@ -0,0 +1,10 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function GET(ctx: Context): Promise<Response> {
return invokeAuthHandler("verifyEmail", ctx);
}
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("verifyEmail", ctx);
}
@@ -0,0 +1,6 @@
import type { Context } from "@wrnexus/core";
import { invokeAuthHandler } from "../api.ts";
export function POST(ctx: Context): Promise<Response> {
return invokeAuthHandler("verifyPhone", ctx);
}
+171
View File
@@ -0,0 +1,171 @@
import type { createAuthHttpHandlers } from "../http/index.ts";
export type AuthHandlerName = keyof ReturnType<typeof createAuthHttpHandlers>;
export type AuthRouteGroup =
| "registration"
| "login"
| "verification"
| "password"
| "invitations"
| "magicLink"
| "otp"
| "mfa"
| "sessions"
| "impersonation"
| "passkeys";
export interface AuthRouteDefinition {
path: string;
group: AuthRouteGroup;
handler: AuthHandlerName;
methods: readonly ("GET" | "POST")[];
}
/** Single source of truth for package-contributed auth endpoints. */
export const AUTH_ROUTE_DEFINITIONS = [
{ path: "/api/auth/register", group: "registration", handler: "register", methods: ["POST"] },
{ path: "/api/auth/login", group: "login", handler: "login", methods: ["POST"] },
{ path: "/api/auth/logout", group: "login", handler: "logout", methods: ["POST"] },
{
path: "/api/auth/verification/request",
group: "verification",
handler: "requestVerification",
methods: ["POST"],
},
{
path: "/api/auth/verify/email",
group: "verification",
handler: "verifyEmail",
methods: ["GET", "POST"],
},
{
path: "/api/auth/verify/phone",
group: "verification",
handler: "verifyPhone",
methods: ["POST"],
},
{
path: "/api/auth/password/request",
group: "password",
handler: "requestPasswordReset",
methods: ["POST"],
},
{
path: "/api/auth/password/reset",
group: "password",
handler: "resetPassword",
methods: ["POST"],
},
{
path: "/api/auth/password/change",
group: "password",
handler: "changePassword",
methods: ["POST"],
},
{
path: "/api/auth/invitations/accept",
group: "invitations",
handler: "acceptInvitation",
methods: ["POST"],
},
{
path: "/api/auth/magic-link/request",
group: "magicLink",
handler: "requestMagicLink",
methods: ["POST"],
},
{
path: "/api/auth/magic-link",
group: "magicLink",
handler: "consumeMagicLink",
methods: ["GET", "POST"],
},
{
path: "/api/auth/otp/login/request",
group: "otp",
handler: "requestOtpLogin",
methods: ["POST"],
},
{
path: "/api/auth/otp/login/complete",
group: "otp",
handler: "completeOtpLogin",
methods: ["POST"],
},
{ path: "/api/auth/otp", group: "otp", handler: "issueOtp", methods: ["POST"] },
{
path: "/api/auth/otp/verify",
group: "otp",
handler: "verifyOtp",
methods: ["POST"],
},
{ path: "/api/auth/totp/setup", group: "mfa", handler: "beginTotp", methods: ["POST"] },
{
path: "/api/auth/totp/confirm",
group: "mfa",
handler: "confirmTotp",
methods: ["POST"],
},
{
path: "/api/auth/totp/disable",
group: "mfa",
handler: "disableTotp",
methods: ["POST"],
},
{
path: "/api/auth/recovery-codes",
group: "mfa",
handler: "recoveryCodes",
methods: ["POST"],
},
{ path: "/api/auth/mfa/otp", group: "mfa", handler: "beginMfaOtp", methods: ["POST"] },
{
path: "/api/auth/mfa/complete",
group: "mfa",
handler: "completeMfa",
methods: ["POST"],
},
{ path: "/api/auth/sessions", group: "sessions", handler: "sessions", methods: ["GET"] },
{
path: "/api/auth/sessions/revoke",
group: "sessions",
handler: "revokeSession",
methods: ["POST"],
},
{
path: "/api/auth/impersonation/start",
group: "impersonation",
handler: "startImpersonation",
methods: ["POST"],
},
{
path: "/api/auth/impersonation/stop",
group: "impersonation",
handler: "stopImpersonation",
methods: ["POST"],
},
{
path: "/api/auth/passkeys/register/options",
group: "passkeys",
handler: "passkeyRegistrationOptions",
methods: ["POST"],
},
{
path: "/api/auth/passkeys/register/verify",
group: "passkeys",
handler: "passkeyRegistrationVerify",
methods: ["POST"],
},
{
path: "/api/auth/passkeys/login/options",
group: "passkeys",
handler: "passkeyAuthenticationOptions",
methods: ["POST"],
},
{
path: "/api/auth/passkeys/login/verify",
group: "passkeys",
handler: "passkeyAuthenticationVerify",
methods: ["POST"],
},
] as const satisfies readonly AuthRouteDefinition[];
+11
View File
@@ -0,0 +1,11 @@
import type { Middleware } from "@wrnexus/core";
import { authSession } from "../middleware.ts";
import { getDefaultAuthEngine, hasDefaultAuthEngine } from "../runtime.ts";
/** Package middleware: hydrates auth state when a default engine is configured. */
const middleware: Middleware = async (ctx, next) => {
if (!hasDefaultAuthEngine()) return next();
return authSession(getDefaultAuthEngine())(ctx, next);
};
export default middleware;
+100
View File
@@ -0,0 +1,100 @@
import type { Context } from "@wrnexus/core";
import type { AuthEngine } from "./engine.ts";
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "./validation.ts";
export interface DefaultAuthRouteOptions {
baseUrl?: string;
csrf?: boolean;
passkey?: AuthPasskeyHttpOptions;
onSignedIn?: (ctx: Context, returnTo?: string) => Response | Promise<Response>;
onSignedOut?: (ctx: Context) => Response | Promise<Response>;
}
interface AuthRuntimeState {
engine?: AuthEngine;
schemas: AuthSchemaSet;
routeOptions: DefaultAuthRouteOptions;
}
/**
* A Symbol.for registry is used instead of module-local variables.
*
* The dev server may import the auth plugin and route modules using
* different module URLs during HMR. Those modules still execute inside
* the same JavaScript global realm, so Symbol.for keeps the runtime
* configuration shared between them.
*/
const AUTH_RUNTIME_STATE_KEY = Symbol.for("@wrnexus/auth:runtime-state:v1");
function runtimeState(): AuthRuntimeState {
const registry = globalThis as unknown as Record<PropertyKey, unknown>;
const existing = registry[AUTH_RUNTIME_STATE_KEY] as AuthRuntimeState | undefined;
if (existing) {
return existing;
}
const created: AuthRuntimeState = {
schemas: resolveAuthSchemas(),
routeOptions: {},
};
registry[AUTH_RUNTIME_STATE_KEY] = created;
return created;
}
export function setDefaultAuthEngine(engine: AuthEngine): void {
runtimeState().engine = engine;
}
/**
* Clear auth state when an application removes config.auth.engine
* during development or when another application starts in the
* same process.
*/
export function clearDefaultAuthEngine(): void {
delete runtimeState().engine;
}
export function setDefaultAuthSchemas(schemas: AuthSchemaOverrides | AuthSchemaSet = {}): void {
runtimeState().schemas = resolveAuthSchemas(schemas);
}
export function setDefaultAuthRouteOptions(options: DefaultAuthRouteOptions = {}): void {
runtimeState().routeOptions = {
...options,
};
}
export function tryGetDefaultAuthEngine(): AuthEngine | undefined {
return runtimeState().engine;
}
export function getDefaultAuthEngine(): AuthEngine {
const engine = runtimeState().engine;
if (!engine) {
throw new Error(
"WRN-AUTH-NOT-CONFIGURED: configure auth.engine or call setDefaultAuthEngine(createAuthEngine(...)) at startup",
);
}
return engine;
}
export function getDefaultAuthSchemas(): AuthSchemaSet {
return runtimeState().schemas;
}
export function getDefaultAuthRouteOptions(): DefaultAuthRouteOptions {
return runtimeState().routeOptions;
}
export function hasDefaultAuthEngine(): boolean {
return Boolean(runtimeState().engine);
}
+45
View File
@@ -0,0 +1,45 @@
export { createAuthEngine, type AuthEngine } from "../engine.ts";
export {
createAuthHttpHandlers,
type AuthHttpOptions,
type AuthPasskeyHttpOptions,
} from "../http/index.ts";
export {
authSession,
requireAuth,
establishAuthSession,
clearAuthSession,
getAuthUser,
getAuthSession,
isAuthenticatedContext,
AUTH_SESSION_KEY,
} from "../middleware.ts";
export {
setDefaultAuthEngine,
clearDefaultAuthEngine,
setDefaultAuthSchemas,
setDefaultAuthRouteOptions,
tryGetDefaultAuthEngine,
getDefaultAuthEngine,
getDefaultAuthSchemas,
getDefaultAuthRouteOptions,
hasDefaultAuthEngine,
type DefaultAuthRouteOptions,
} from "../runtime.ts";
export { MemoryAuthStore } from "../stores/memory.ts";
export { SqlAuthStore } from "../stores/sql.ts";
export * from "../types.ts";
export * from "../validation.ts";
export { createAuthSecretProtector } from "../protector.ts";
export {
normalizeEmail,
normalizePhone,
normalizeUsername,
normalizeIdentity,
inferIdentityType,
publicUser,
safeAuthReturnTo,
} from "../normalize.ts";
+83
View File
@@ -0,0 +1,83 @@
import type {
AuthIdentity,
AuthSecurityEvent,
AuthSession,
AuthUser,
LoginAttempt,
OAuthAccount,
OneTimeToken,
OtpChallenge,
PasskeyCredential,
PasswordCredential,
RecoveryCodeRecord,
TotpCredential,
TrustedDevice,
} from "./types.ts";
export interface AuthStore {
createUser(user: AuthUser): Promise<void>;
updateUser(user: AuthUser): Promise<void>;
findUserById(id: string): Promise<AuthUser | undefined>;
listUsers(): Promise<AuthUser[]>;
createIdentity(identity: AuthIdentity): Promise<void>;
updateIdentity(identity: AuthIdentity): Promise<void>;
findIdentity(
type: AuthIdentity["type"],
normalizedValue: string,
): Promise<AuthIdentity | undefined>;
listIdentities(userId: string): Promise<AuthIdentity[]>;
setPassword(credential: PasswordCredential): Promise<void>;
getPassword(userId: string): Promise<PasswordCredential | undefined>;
createSession(session: AuthSession): Promise<void>;
updateSession(session: AuthSession): Promise<void>;
findSession(id: string): Promise<AuthSession | undefined>;
listSessions(userId: string): Promise<AuthSession[]>;
deleteSession(id: string): Promise<void>;
createTrustedDevice(device: TrustedDevice): Promise<void>;
updateTrustedDevice(device: TrustedDevice): Promise<void>;
findTrustedDeviceByFingerprint(
userId: string,
fingerprintHash: string,
): Promise<TrustedDevice | undefined>;
listTrustedDevices(userId: string): Promise<TrustedDevice[]>;
createToken(token: OneTimeToken): Promise<void>;
updateToken(token: OneTimeToken): Promise<void>;
findTokenByHash(hash: string): Promise<OneTimeToken | undefined>;
createOtp(challenge: OtpChallenge): Promise<void>;
updateOtp(challenge: OtpChallenge): Promise<void>;
findOtp(id: string): Promise<OtpChallenge | undefined>;
createTotp(credential: TotpCredential): Promise<void>;
updateTotp(credential: TotpCredential): Promise<void>;
listTotp(userId: string): Promise<TotpCredential[]>;
deleteTotp(id: string): Promise<void>;
createRecoveryCodes(codes: RecoveryCodeRecord[]): Promise<void>;
updateRecoveryCode(code: RecoveryCodeRecord): Promise<void>;
listRecoveryCodes(userId: string): Promise<RecoveryCodeRecord[]>;
deleteRecoveryCodes(userId: string): Promise<void>;
createPasskey(credential: PasskeyCredential): Promise<void>;
updatePasskey(credential: PasskeyCredential): Promise<void>;
findPasskeyByCredentialId(credentialId: string): Promise<PasskeyCredential | undefined>;
listPasskeys(userId: string): Promise<PasskeyCredential[]>;
deletePasskey(id: string): Promise<void>;
createOAuthAccount(account: OAuthAccount): Promise<void>;
updateOAuthAccount(account: OAuthAccount): Promise<void>;
findOAuthAccount(provider: string, providerAccountId: string): Promise<OAuthAccount | undefined>;
listOAuthAccounts(userId: string): Promise<OAuthAccount[]>;
deleteOAuthAccount(id: string): Promise<void>;
createLoginAttempt(attempt: LoginAttempt): Promise<void>;
listRecentLoginAttempts(identifier: string, since: number): Promise<LoginAttempt[]>;
createSecurityEvent(event: AuthSecurityEvent): Promise<void>;
listSecurityEvents(userId: string, limit?: number): Promise<AuthSecurityEvent[]>;
}
+318
View File
@@ -0,0 +1,318 @@
import type { AuthStore } from "../store.ts";
import { inferIdentityType, normalizeIdentity } from "../normalize.ts";
import type {
AuthIdentity,
AuthSecurityEvent,
AuthSession,
AuthUser,
LoginAttempt,
OAuthAccount,
OneTimeToken,
OtpChallenge,
PasskeyCredential,
PasswordCredential,
RecoveryCodeRecord,
TotpCredential,
TrustedDevice,
} from "../types.ts";
function clone<T>(value: T): T {
return structuredClone(value);
}
export class MemoryAuthStore implements AuthStore {
private readonly users = new Map<string, AuthUser>();
private readonly identities = new Map<string, AuthIdentity>();
private readonly passwords = new Map<string, PasswordCredential>();
private readonly sessions = new Map<string, AuthSession>();
private readonly trustedDevices = new Map<string, TrustedDevice>();
private readonly tokens = new Map<string, OneTimeToken>();
private readonly otps = new Map<string, OtpChallenge>();
private readonly totp = new Map<string, TotpCredential>();
private readonly recoveryCodes = new Map<string, RecoveryCodeRecord>();
private readonly passkeys = new Map<string, PasskeyCredential>();
private readonly oauthAccounts = new Map<string, OAuthAccount>();
private readonly attempts: LoginAttempt[] = [];
private readonly events: AuthSecurityEvent[] = [];
async createUser(user: AuthUser): Promise<void> {
if (this.users.has(user.id)) throw new Error(`WRN-AUTH-USER-EXISTS: ${user.id}`);
this.users.set(user.id, clone(user));
}
async updateUser(user: AuthUser): Promise<void> {
if (!this.users.has(user.id)) throw new Error(`WRN-AUTH-USER-MISSING: ${user.id}`);
this.users.set(user.id, clone(user));
}
async findUserById(id: string): Promise<AuthUser | undefined> {
const value = this.users.get(id);
return value ? clone(value) : undefined;
}
async listUsers(): Promise<AuthUser[]> {
return [...this.users.values()].map(clone);
}
async createIdentity(identity: AuthIdentity): Promise<void> {
const key = `${identity.type}:${identity.normalizedValue}`;
if (
this.identities.has(key) ||
[...this.identities.values()].some((item) => item.id === identity.id)
) {
throw new Error("WRN-AUTH-IDENTITY-EXISTS");
}
this.identities.set(key, clone(identity));
}
async updateIdentity(identity: AuthIdentity): Promise<void> {
const currentEntry = [...this.identities.entries()].find(
([, existing]) => existing.id === identity.id,
);
if (!currentEntry) throw new Error("WRN-AUTH-IDENTITY-MISSING");
const [currentKey, current] = currentEntry;
if (identity.userId !== current.userId || identity.type !== current.type) {
throw new Error("WRN-AUTH-IDENTITY-IMMUTABLE");
}
const nextKey = `${identity.type}:${identity.normalizedValue}`;
const collision = this.identities.get(nextKey);
if (collision && collision.id !== identity.id) {
throw new Error("WRN-AUTH-IDENTITY-EXISTS");
}
if (currentKey !== nextKey) this.identities.delete(currentKey);
this.identities.set(nextKey, clone(identity));
}
async findIdentity(
type: AuthIdentity["type"],
normalizedValue: string,
): Promise<AuthIdentity | undefined> {
const value = this.identities.get(`${type}:${normalizedValue}`);
return value ? clone(value) : undefined;
}
async listIdentities(userId: string): Promise<AuthIdentity[]> {
return [...this.identities.values()].filter((item) => item.userId === userId).map(clone);
}
async setPassword(credential: PasswordCredential): Promise<void> {
this.passwords.set(credential.userId, clone(credential));
}
async getPassword(userId: string): Promise<PasswordCredential | undefined> {
const value = this.passwords.get(userId);
return value ? clone(value) : undefined;
}
async createSession(session: AuthSession): Promise<void> {
if (this.sessions.has(session.id)) throw new Error("WRN-AUTH-SESSION-EXISTS");
this.sessions.set(session.id, clone(session));
}
async updateSession(session: AuthSession): Promise<void> {
this.sessions.set(session.id, clone(session));
}
async findSession(id: string): Promise<AuthSession | undefined> {
const value = this.sessions.get(id);
return value ? clone(value) : undefined;
}
async listSessions(userId: string): Promise<AuthSession[]> {
return [...this.sessions.values()].filter((item) => item.userId === userId).map(clone);
}
async deleteSession(id: string): Promise<void> {
this.sessions.delete(id);
}
async createTrustedDevice(device: TrustedDevice): Promise<void> {
if (
this.trustedDevices.has(device.id) ||
[...this.trustedDevices.values()].some(
(item) => item.userId === device.userId && item.fingerprintHash === device.fingerprintHash,
)
) {
throw new Error("WRN-AUTH-TRUSTED-DEVICE-EXISTS");
}
this.trustedDevices.set(device.id, clone(device));
}
async updateTrustedDevice(device: TrustedDevice): Promise<void> {
this.trustedDevices.set(device.id, clone(device));
}
async findTrustedDeviceByFingerprint(
userId: string,
fingerprintHash: string,
): Promise<TrustedDevice | undefined> {
const value = [...this.trustedDevices.values()].find(
(item) => item.userId === userId && item.fingerprintHash === fingerprintHash,
);
return value ? clone(value) : undefined;
}
async listTrustedDevices(userId: string): Promise<TrustedDevice[]> {
return [...this.trustedDevices.values()].filter((item) => item.userId === userId).map(clone);
}
async createToken(token: OneTimeToken): Promise<void> {
if (
this.tokens.has(token.tokenHash) ||
[...this.tokens.values()].some((item) => item.id === token.id)
) {
throw new Error("WRN-AUTH-TOKEN-EXISTS");
}
this.tokens.set(token.tokenHash, clone(token));
}
async updateToken(token: OneTimeToken): Promise<void> {
this.tokens.set(token.tokenHash, clone(token));
}
async findTokenByHash(hash: string): Promise<OneTimeToken | undefined> {
const value = this.tokens.get(hash);
return value ? clone(value) : undefined;
}
async createOtp(challenge: OtpChallenge): Promise<void> {
if (this.otps.has(challenge.id)) throw new Error("WRN-AUTH-OTP-EXISTS");
this.otps.set(challenge.id, clone(challenge));
}
async updateOtp(challenge: OtpChallenge): Promise<void> {
this.otps.set(challenge.id, clone(challenge));
}
async findOtp(id: string): Promise<OtpChallenge | undefined> {
const value = this.otps.get(id);
return value ? clone(value) : undefined;
}
async createTotp(credential: TotpCredential): Promise<void> {
if (this.totp.has(credential.id)) throw new Error("WRN-AUTH-TOTP-EXISTS");
this.totp.set(credential.id, clone(credential));
}
async updateTotp(credential: TotpCredential): Promise<void> {
this.totp.set(credential.id, clone(credential));
}
async listTotp(userId: string): Promise<TotpCredential[]> {
return [...this.totp.values()].filter((item) => item.userId === userId).map(clone);
}
async deleteTotp(id: string): Promise<void> {
this.totp.delete(id);
}
async createRecoveryCodes(codes: RecoveryCodeRecord[]): Promise<void> {
const incoming = new Set<string>();
for (const code of codes) {
if (incoming.has(code.id) || this.recoveryCodes.has(code.id)) {
throw new Error("WRN-AUTH-RECOVERY-CODE-EXISTS");
}
incoming.add(code.id);
}
for (const code of codes) this.recoveryCodes.set(code.id, clone(code));
}
async updateRecoveryCode(code: RecoveryCodeRecord): Promise<void> {
this.recoveryCodes.set(code.id, clone(code));
}
async listRecoveryCodes(userId: string): Promise<RecoveryCodeRecord[]> {
return [...this.recoveryCodes.values()].filter((item) => item.userId === userId).map(clone);
}
async deleteRecoveryCodes(userId: string): Promise<void> {
for (const [id, code] of this.recoveryCodes) {
if (code.userId === userId) this.recoveryCodes.delete(id);
}
}
async createPasskey(credential: PasskeyCredential): Promise<void> {
if (
this.passkeys.has(credential.id) ||
[...this.passkeys.values()].some((item) => item.credentialId === credential.credentialId)
) {
throw new Error("WRN-AUTH-PASSKEY-EXISTS");
}
this.passkeys.set(credential.id, clone(credential));
}
async updatePasskey(credential: PasskeyCredential): Promise<void> {
const current = this.passkeys.get(credential.id);
if (!current) throw new Error("WRN-AUTH-PASSKEY-MISSING");
if (
credential.userId !== current.userId ||
credential.credentialId !== current.credentialId ||
credential.createdAt !== current.createdAt
) {
throw new Error("WRN-AUTH-PASSKEY-IMMUTABLE");
}
this.passkeys.set(credential.id, clone(credential));
}
async findPasskeyByCredentialId(credentialId: string): Promise<PasskeyCredential | undefined> {
const value = [...this.passkeys.values()].find((item) => item.credentialId === credentialId);
return value ? clone(value) : undefined;
}
async listPasskeys(userId: string): Promise<PasskeyCredential[]> {
return [...this.passkeys.values()].filter((item) => item.userId === userId).map(clone);
}
async deletePasskey(id: string): Promise<void> {
this.passkeys.delete(id);
}
async createOAuthAccount(account: OAuthAccount): Promise<void> {
if (
this.oauthAccounts.has(account.id) ||
[...this.oauthAccounts.values()].some(
(item) =>
item.provider === account.provider &&
item.providerAccountId === account.providerAccountId,
)
) {
throw new Error("WRN-AUTH-OAUTH-ACCOUNT-EXISTS");
}
this.oauthAccounts.set(account.id, clone(account));
}
async updateOAuthAccount(account: OAuthAccount): Promise<void> {
const current = this.oauthAccounts.get(account.id);
if (!current) throw new Error("WRN-AUTH-OAUTH-ACCOUNT-MISSING");
if (
account.userId !== current.userId ||
account.provider !== current.provider ||
account.providerAccountId !== current.providerAccountId ||
account.createdAt !== current.createdAt
) {
throw new Error("WRN-AUTH-OAUTH-ACCOUNT-IMMUTABLE");
}
this.oauthAccounts.set(account.id, clone(account));
}
async findOAuthAccount(
provider: string,
providerAccountId: string,
): Promise<OAuthAccount | undefined> {
const value = [...this.oauthAccounts.values()].find(
(item) => item.provider === provider && item.providerAccountId === providerAccountId,
);
return value ? clone(value) : undefined;
}
async listOAuthAccounts(userId: string): Promise<OAuthAccount[]> {
return [...this.oauthAccounts.values()].filter((item) => item.userId === userId).map(clone);
}
async deleteOAuthAccount(id: string): Promise<void> {
this.oauthAccounts.delete(id);
}
async createLoginAttempt(attempt: LoginAttempt): Promise<void> {
if (this.attempts.some((item) => item.id === attempt.id)) {
throw new Error("WRN-AUTH-LOGIN-ATTEMPT-EXISTS");
}
const identifier = attempt.identifier
? normalizeIdentity(inferIdentityType(attempt.identifier), attempt.identifier)
: undefined;
this.attempts.push(clone({ ...attempt, identifier }));
}
async listRecentLoginAttempts(identifier: string, since: number): Promise<LoginAttempt[]> {
const normalized = normalizeIdentity(inferIdentityType(identifier), identifier);
return this.attempts
.filter((item) => {
if (item.createdAt < since || !item.identifier) return false;
return (
normalizeIdentity(inferIdentityType(item.identifier), item.identifier) === normalized
);
})
.map(clone);
}
async createSecurityEvent(event: AuthSecurityEvent): Promise<void> {
if (this.events.some((item) => item.id === event.id)) {
throw new Error("WRN-AUTH-SECURITY-EVENT-EXISTS");
}
this.events.push(clone(event));
}
async listSecurityEvents(userId: string, limit = 100): Promise<AuthSecurityEvent[]> {
return this.events
.filter((item) => item.userId === userId)
.sort((left, right) => right.createdAt - left.createdAt)
.slice(0, limit)
.map(clone);
}
}
+663
View File
@@ -0,0 +1,663 @@
import type { Db, Row } from "@wrnexus/db";
import type { AuthStore } from "../store.ts";
import { inferIdentityType, normalizeIdentity } from "../normalize.ts";
import type {
AuthIdentity,
AuthSecurityEvent,
AuthSession,
AuthUser,
LoginAttempt,
OAuthAccount,
OneTimeToken,
OtpChallenge,
PasskeyCredential,
PasswordCredential,
RecoveryCodeRecord,
TotpCredential,
TrustedDevice,
} from "../types.ts";
function bool(value: unknown): boolean {
return value === true || value === 1 || value === "1";
}
function json<T>(value: unknown, fallback: T): T {
if (typeof value !== "string" || !value) return fallback;
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
function placeholders(sql: string, dialect: string): string {
if (dialect !== "postgres") return sql;
let index = 0;
return sql.replace(/\?/g, () => `$${++index}`);
}
export class SqlAuthStore implements AuthStore {
constructor(private readonly db: Db) {}
private sql(value: string): string {
return placeholders(value, this.db.driver.dialect);
}
private async one(sql: string, params: unknown[] = []): Promise<Row | undefined> {
return (await this.db.one(this.sql(sql), params)) ?? undefined;
}
private async all(sql: string, params: unknown[] = []): Promise<Row[]> {
return this.db.all(this.sql(sql), params);
}
private exec(sql: string, params: unknown[] = []) {
return this.db.exec(this.sql(sql), params);
}
async createUser(user: AuthUser): Promise<void> {
await this.exec(
"INSERT INTO wrn_auth_users (id,username,display_name,avatar_url,status,roles_json,email_verified,phone_verified,mfa_enabled,locale,timezone,created_at,updated_at,last_login_at,metadata_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
[
user.id,
user.username,
user.displayName,
user.avatarUrl,
user.status,
JSON.stringify(user.roles),
Number(user.emailVerified),
Number(user.phoneVerified),
Number(user.mfaEnabled),
user.locale,
user.timezone,
user.createdAt,
user.updatedAt,
user.lastLoginAt,
JSON.stringify(user.metadata ?? {}),
],
);
}
async updateUser(user: AuthUser): Promise<void> {
await this.exec(
"UPDATE wrn_auth_users SET username=?,display_name=?,avatar_url=?,status=?,roles_json=?,email_verified=?,phone_verified=?,mfa_enabled=?,locale=?,timezone=?,updated_at=?,last_login_at=?,metadata_json=? WHERE id=?",
[
user.username,
user.displayName,
user.avatarUrl,
user.status,
JSON.stringify(user.roles),
Number(user.emailVerified),
Number(user.phoneVerified),
Number(user.mfaEnabled),
user.locale,
user.timezone,
user.updatedAt,
user.lastLoginAt,
JSON.stringify(user.metadata ?? {}),
user.id,
],
);
}
private user(row: Row | undefined): AuthUser | undefined {
if (!row) return undefined;
return {
id: String(row.id),
username: row.username ? String(row.username) : undefined,
displayName: row.display_name ? String(row.display_name) : undefined,
avatarUrl: row.avatar_url ? String(row.avatar_url) : undefined,
status: String(row.status) as AuthUser["status"],
roles: json(row.roles_json, []),
emailVerified: bool(row.email_verified),
phoneVerified: bool(row.phone_verified),
mfaEnabled: bool(row.mfa_enabled),
locale: row.locale ? String(row.locale) : undefined,
timezone: row.timezone ? String(row.timezone) : undefined,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
lastLoginAt: row.last_login_at == null ? undefined : Number(row.last_login_at),
metadata: json(row.metadata_json, {}),
};
}
async findUserById(id: string): Promise<AuthUser | undefined> {
return this.user(await this.one("SELECT * FROM wrn_auth_users WHERE id=?", [id]));
}
async listUsers(): Promise<AuthUser[]> {
return (await this.all("SELECT * FROM wrn_auth_users ORDER BY created_at")).map((row) =>
this.user(row)!,
);
}
async createIdentity(x: AuthIdentity): Promise<void> {
await this.exec(
"INSERT INTO wrn_auth_identities (id,user_id,type,value,normalized_value,is_primary,verified_at,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
[
x.id,
x.userId,
x.type,
x.value,
x.normalizedValue,
Number(x.primary),
x.verifiedAt,
x.createdAt,
x.updatedAt,
],
);
}
async updateIdentity(x: AuthIdentity): Promise<void> {
await this.exec(
"UPDATE wrn_auth_identities SET value=?,normalized_value=?,is_primary=?,verified_at=?,updated_at=? WHERE id=?",
[x.value, x.normalizedValue, Number(x.primary), x.verifiedAt, x.updatedAt, x.id],
);
}
private identity(row: Row | undefined): AuthIdentity | undefined {
if (!row) return;
return {
id: String(row.id),
userId: String(row.user_id),
type: String(row.type) as AuthIdentity["type"],
value: String(row.value),
normalizedValue: String(row.normalized_value),
primary: bool(row.is_primary),
verifiedAt: row.verified_at == null ? undefined : Number(row.verified_at),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
}
async findIdentity(type: AuthIdentity["type"], value: string) {
return this.identity(
await this.one("SELECT * FROM wrn_auth_identities WHERE type=? AND normalized_value=?", [
type,
value,
]),
);
}
async listIdentities(userId: string) {
return (
await this.all(
"SELECT * FROM wrn_auth_identities WHERE user_id=? ORDER BY is_primary DESC, created_at",
[userId],
)
).map((r) => this.identity(r)!);
}
async setPassword(x: PasswordCredential) {
const current = await this.getPassword(x.userId);
if (current)
await this.exec(
"UPDATE wrn_auth_password_credentials SET password_hash=?,password_version=?,changed_at=?,must_change=? WHERE user_id=?",
[x.passwordHash, x.passwordVersion, x.changedAt, Number(x.mustChange), x.userId],
);
else
await this.exec(
"INSERT INTO wrn_auth_password_credentials (user_id,password_hash,password_version,changed_at,must_change) VALUES (?,?,?,?,?)",
[x.userId, x.passwordHash, x.passwordVersion, x.changedAt, Number(x.mustChange)],
);
}
async getPassword(userId: string) {
const r = await this.one("SELECT * FROM wrn_auth_password_credentials WHERE user_id=?", [
userId,
]);
return r
? {
userId: String(r.user_id),
passwordHash: String(r.password_hash),
passwordVersion: Number(r.password_version),
changedAt: Number(r.changed_at),
mustChange: bool(r.must_change),
}
: undefined;
}
async createSession(x: AuthSession) {
await this.exec(
"INSERT INTO wrn_auth_sessions (id,user_id,device_id,created_at,last_seen_at,expires_at,absolute_expires_at,ip,user_agent,trusted,revoked_at,revoke_reason,metadata_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
[
x.id,
x.userId,
x.deviceId,
x.createdAt,
x.lastSeenAt,
x.expiresAt,
x.absoluteExpiresAt,
x.ip,
x.userAgent,
Number(x.trusted),
x.revokedAt,
x.revokeReason,
JSON.stringify(x.metadata ?? {}),
],
);
}
async updateSession(x: AuthSession) {
await this.exec(
"UPDATE wrn_auth_sessions SET last_seen_at=?,expires_at=?,trusted=?,revoked_at=?,revoke_reason=?,metadata_json=? WHERE id=?",
[
x.lastSeenAt,
x.expiresAt,
Number(x.trusted),
x.revokedAt,
x.revokeReason,
JSON.stringify(x.metadata ?? {}),
x.id,
],
);
}
private session(r: Row | undefined): AuthSession | undefined {
if (!r) return;
return {
id: String(r.id),
userId: String(r.user_id),
deviceId: String(r.device_id),
createdAt: Number(r.created_at),
lastSeenAt: Number(r.last_seen_at),
expiresAt: Number(r.expires_at),
absoluteExpiresAt: Number(r.absolute_expires_at),
ip: r.ip ? String(r.ip) : undefined,
userAgent: r.user_agent ? String(r.user_agent) : undefined,
trusted: bool(r.trusted),
revokedAt: r.revoked_at == null ? undefined : Number(r.revoked_at),
revokeReason: r.revoke_reason ? String(r.revoke_reason) : undefined,
metadata: json(r.metadata_json, {}),
};
}
async findSession(id: string) {
return this.session(await this.one("SELECT * FROM wrn_auth_sessions WHERE id=?", [id]));
}
async listSessions(userId: string) {
return (
await this.all("SELECT * FROM wrn_auth_sessions WHERE user_id=? ORDER BY last_seen_at DESC", [
userId,
])
).map((r) => this.session(r)!);
}
async deleteSession(id: string) {
await this.exec("DELETE FROM wrn_auth_sessions WHERE id=?", [id]);
}
async createTrustedDevice(x: TrustedDevice) {
await this.exec(
"INSERT INTO wrn_auth_trusted_devices (id,user_id,name,fingerprint_hash,created_at,last_seen_at,expires_at,revoked_at) VALUES (?,?,?,?,?,?,?,?)",
[
x.id,
x.userId,
x.name,
x.fingerprintHash,
x.createdAt,
x.lastSeenAt,
x.expiresAt,
x.revokedAt,
],
);
}
async updateTrustedDevice(x: TrustedDevice) {
await this.exec(
"UPDATE wrn_auth_trusted_devices SET name=?,last_seen_at=?,expires_at=?,revoked_at=? WHERE id=?",
[x.name, x.lastSeenAt, x.expiresAt, x.revokedAt, x.id],
);
}
private device(r: Row | undefined): TrustedDevice | undefined {
if (!r) return;
return {
id: String(r.id),
userId: String(r.user_id),
name: String(r.name),
fingerprintHash: String(r.fingerprint_hash),
createdAt: Number(r.created_at),
lastSeenAt: Number(r.last_seen_at),
expiresAt: Number(r.expires_at),
revokedAt: r.revoked_at == null ? undefined : Number(r.revoked_at),
};
}
async findTrustedDeviceByFingerprint(userId: string, hash: string) {
return this.device(
await this.one(
"SELECT * FROM wrn_auth_trusted_devices WHERE user_id=? AND fingerprint_hash=?",
[userId, hash],
),
);
}
async listTrustedDevices(userId: string) {
return (
await this.all(
"SELECT * FROM wrn_auth_trusted_devices WHERE user_id=? ORDER BY last_seen_at DESC",
[userId],
)
).map((r) => this.device(r)!);
}
async createToken(x: OneTimeToken) {
await this.exec(
"INSERT INTO wrn_auth_tokens (id,user_id,purpose,token_hash,target,created_at,expires_at,used_at,attempts,max_attempts,metadata_json) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
[
x.id,
x.userId,
x.purpose,
x.tokenHash,
x.target,
x.createdAt,
x.expiresAt,
x.usedAt,
x.attempts,
x.maxAttempts,
JSON.stringify(x.metadata ?? {}),
],
);
}
async updateToken(x: OneTimeToken) {
await this.exec("UPDATE wrn_auth_tokens SET used_at=?,attempts=?,metadata_json=? WHERE id=?", [
x.usedAt,
x.attempts,
JSON.stringify(x.metadata ?? {}),
x.id,
]);
}
private token(r: Row | undefined): OneTimeToken | undefined {
if (!r) return;
return {
id: String(r.id),
userId: String(r.user_id),
purpose: String(r.purpose) as OneTimeToken["purpose"],
tokenHash: String(r.token_hash),
target: r.target ? String(r.target) : undefined,
createdAt: Number(r.created_at),
expiresAt: Number(r.expires_at),
usedAt: r.used_at == null ? undefined : Number(r.used_at),
attempts: Number(r.attempts),
maxAttempts: Number(r.max_attempts),
metadata: json(r.metadata_json, {}),
};
}
async findTokenByHash(hash: string) {
return this.token(await this.one("SELECT * FROM wrn_auth_tokens WHERE token_hash=?", [hash]));
}
async createOtp(x: OtpChallenge) {
await this.exec(
"INSERT INTO wrn_auth_otp_challenges (id,user_id,method,purpose,destination,code_hash,created_at,expires_at,used_at,attempts,max_attempts) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
[
x.id,
x.userId,
x.method,
x.purpose,
x.destination,
x.codeHash,
x.createdAt,
x.expiresAt,
x.usedAt,
x.attempts,
x.maxAttempts,
],
);
}
async updateOtp(x: OtpChallenge) {
await this.exec("UPDATE wrn_auth_otp_challenges SET used_at=?,attempts=? WHERE id=?", [
x.usedAt,
x.attempts,
x.id,
]);
}
async findOtp(id: string) {
const r = await this.one("SELECT * FROM wrn_auth_otp_challenges WHERE id=?", [id]);
return r
? {
id: String(r.id),
userId: String(r.user_id),
method: String(r.method) as OtpChallenge["method"],
purpose: String(r.purpose ?? "verification") as OtpChallenge["purpose"],
destination: String(r.destination),
codeHash: String(r.code_hash),
createdAt: Number(r.created_at),
expiresAt: Number(r.expires_at),
usedAt: r.used_at == null ? undefined : Number(r.used_at),
attempts: Number(r.attempts),
maxAttempts: Number(r.max_attempts),
}
: undefined;
}
async createTotp(x: TotpCredential) {
await this.exec(
"INSERT INTO wrn_auth_totp_credentials (id,user_id,label,secret,created_at,verified_at,last_counter) VALUES (?,?,?,?,?,?,?)",
[x.id, x.userId, x.label, x.secret, x.createdAt, x.verifiedAt, x.lastCounter],
);
}
async updateTotp(x: TotpCredential) {
await this.exec(
"UPDATE wrn_auth_totp_credentials SET label=?,verified_at=?,last_counter=? WHERE id=?",
[x.label, x.verifiedAt, x.lastCounter, x.id],
);
}
private totpRow(r: Row): TotpCredential {
return {
id: String(r.id),
userId: String(r.user_id),
label: String(r.label),
secret: String(r.secret),
createdAt: Number(r.created_at),
verifiedAt: r.verified_at == null ? undefined : Number(r.verified_at),
lastCounter: r.last_counter == null ? undefined : Number(r.last_counter),
};
}
async listTotp(userId: string) {
return (
await this.all("SELECT * FROM wrn_auth_totp_credentials WHERE user_id=?", [userId])
).map((r) => this.totpRow(r));
}
async deleteTotp(id: string) {
await this.exec("DELETE FROM wrn_auth_totp_credentials WHERE id=?", [id]);
}
async createRecoveryCodes(codes: RecoveryCodeRecord[]) {
for (const x of codes)
await this.exec(
"INSERT INTO wrn_auth_recovery_codes (id,user_id,code_hash,created_at,used_at) VALUES (?,?,?,?,?)",
[x.id, x.userId, x.codeHash, x.createdAt, x.usedAt],
);
}
async updateRecoveryCode(x: RecoveryCodeRecord) {
await this.exec("UPDATE wrn_auth_recovery_codes SET used_at=? WHERE id=?", [x.usedAt, x.id]);
}
async listRecoveryCodes(userId: string) {
return (await this.all("SELECT * FROM wrn_auth_recovery_codes WHERE user_id=?", [userId])).map(
(r) => ({
id: String(r.id),
userId: String(r.user_id),
codeHash: String(r.code_hash),
createdAt: Number(r.created_at),
usedAt: r.used_at == null ? undefined : Number(r.used_at),
}),
);
}
async deleteRecoveryCodes(userId: string) {
await this.exec("DELETE FROM wrn_auth_recovery_codes WHERE user_id=?", [userId]);
}
async createPasskey(x: PasskeyCredential) {
await this.exec(
"INSERT INTO wrn_auth_passkeys (id,user_id,credential_id,public_key,counter,transports_json,name,created_at,last_used_at,backed_up,device_type) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
[
x.id,
x.userId,
x.credentialId,
x.publicKey,
x.counter,
JSON.stringify(x.transports),
x.name,
x.createdAt,
x.lastUsedAt,
x.backedUp == null ? undefined : Number(x.backedUp),
x.deviceType,
],
);
}
async updatePasskey(x: PasskeyCredential) {
await this.exec(
"UPDATE wrn_auth_passkeys SET counter=?,transports_json=?,name=?,last_used_at=?,backed_up=?,device_type=? WHERE id=?",
[
x.counter,
JSON.stringify(x.transports),
x.name,
x.lastUsedAt,
x.backedUp == null ? undefined : Number(x.backedUp),
x.deviceType,
x.id,
],
);
}
private passkey(r: Row | undefined): PasskeyCredential | undefined {
if (!r) return;
return {
id: String(r.id),
userId: String(r.user_id),
credentialId: String(r.credential_id),
publicKey: String(r.public_key),
counter: Number(r.counter),
transports: json(r.transports_json, []),
name: String(r.name),
createdAt: Number(r.created_at),
lastUsedAt: r.last_used_at == null ? undefined : Number(r.last_used_at),
backedUp: r.backed_up == null ? undefined : bool(r.backed_up),
deviceType: r.device_type ? String(r.device_type) : undefined,
};
}
async findPasskeyByCredentialId(id: string) {
return this.passkey(
await this.one("SELECT * FROM wrn_auth_passkeys WHERE credential_id=?", [id]),
);
}
async listPasskeys(userId: string) {
return (await this.all("SELECT * FROM wrn_auth_passkeys WHERE user_id=?", [userId])).map((r) =>
this.passkey(r)!,
);
}
async deletePasskey(id: string) {
await this.exec("DELETE FROM wrn_auth_passkeys WHERE id=?", [id]);
}
async createOAuthAccount(x: OAuthAccount) {
await this.exec(
"INSERT INTO wrn_auth_oauth_accounts (id,user_id,provider,provider_account_id,email,access_token,refresh_token,token_expires_at,scope,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
[
x.id,
x.userId,
x.provider,
x.providerAccountId,
x.email,
x.accessToken,
x.refreshToken,
x.tokenExpiresAt,
x.scope,
x.createdAt,
x.updatedAt,
],
);
}
async updateOAuthAccount(x: OAuthAccount) {
await this.exec(
"UPDATE wrn_auth_oauth_accounts SET email=?,access_token=?,refresh_token=?,token_expires_at=?,scope=?,updated_at=? WHERE id=?",
[x.email, x.accessToken, x.refreshToken, x.tokenExpiresAt, x.scope, x.updatedAt, x.id],
);
}
private oauth(r: Row | undefined): OAuthAccount | undefined {
if (!r) return;
return {
id: String(r.id),
userId: String(r.user_id),
provider: String(r.provider),
providerAccountId: String(r.provider_account_id),
email: r.email ? String(r.email) : undefined,
accessToken: r.access_token ? String(r.access_token) : undefined,
refreshToken: r.refresh_token ? String(r.refresh_token) : undefined,
tokenExpiresAt: r.token_expires_at == null ? undefined : Number(r.token_expires_at),
scope: r.scope ? String(r.scope) : undefined,
createdAt: Number(r.created_at),
updatedAt: Number(r.updated_at),
};
}
async findOAuthAccount(provider: string, id: string) {
return this.oauth(
await this.one(
"SELECT * FROM wrn_auth_oauth_accounts WHERE provider=? AND provider_account_id=?",
[provider, id],
),
);
}
async listOAuthAccounts(userId: string) {
return (await this.all("SELECT * FROM wrn_auth_oauth_accounts WHERE user_id=?", [userId])).map(
(r) => this.oauth(r)!,
);
}
async deleteOAuthAccount(id: string) {
await this.exec("DELETE FROM wrn_auth_oauth_accounts WHERE id=?", [id]);
}
async createLoginAttempt(x: LoginAttempt) {
const identifier = x.identifier
? normalizeIdentity(inferIdentityType(x.identifier), x.identifier)
: undefined;
await this.exec(
"INSERT INTO wrn_auth_login_attempts (id,identifier,user_id,success,reason,ip,user_agent,created_at,risk_score,risk_level) VALUES (?,?,?,?,?,?,?,?,?,?)",
[
x.id,
identifier,
x.userId,
Number(x.success),
x.reason,
x.ip,
x.userAgent,
x.createdAt,
x.riskScore,
x.riskLevel,
],
);
}
async listRecentLoginAttempts(identifier: string, since: number) {
const normalized = normalizeIdentity(inferIdentityType(identifier), identifier);
const rows = await this.all(
"SELECT * FROM wrn_auth_login_attempts WHERE identifier=? AND created_at>=? ORDER BY created_at DESC",
[normalized, since],
);
return rows.map((r) => ({
id: String(r.id),
identifier: r.identifier ? String(r.identifier) : undefined,
userId: r.user_id ? String(r.user_id) : undefined,
success: bool(r.success),
reason: r.reason ? String(r.reason) : undefined,
ip: r.ip ? String(r.ip) : undefined,
userAgent: r.user_agent ? String(r.user_agent) : undefined,
createdAt: Number(r.created_at),
riskScore: Number(r.risk_score),
riskLevel: String(r.risk_level) as LoginAttempt["riskLevel"],
}));
}
async createSecurityEvent(x: AuthSecurityEvent) {
await this.exec(
"INSERT INTO wrn_auth_security_events (id,user_id,type,severity,actor_user_id,session_id,ip,user_agent,created_at,data_json) VALUES (?,?,?,?,?,?,?,?,?,?)",
[
x.id,
x.userId,
x.type,
x.severity,
x.actorUserId,
x.sessionId,
x.ip,
x.userAgent,
x.createdAt,
JSON.stringify(x.data ?? {}),
],
);
}
async listSecurityEvents(userId: string, limit = 100) {
const rows = await this.all(
"SELECT * FROM wrn_auth_security_events WHERE user_id=? ORDER BY created_at DESC LIMIT ?",
[userId, limit],
);
return rows.map((r) => ({
id: String(r.id),
userId: r.user_id ? String(r.user_id) : undefined,
type: String(r.type),
severity: String(r.severity) as AuthSecurityEvent["severity"],
actorUserId: r.actor_user_id ? String(r.actor_user_id) : undefined,
sessionId: r.session_id ? String(r.session_id) : undefined,
ip: r.ip ? String(r.ip) : undefined,
userAgent: r.user_agent ? String(r.user_agent) : undefined,
createdAt: Number(r.created_at),
data: json(r.data_json, {}),
}));
}
}
+186
View File
@@ -0,0 +1,186 @@
import { constantTimeEqual } from "../crypto.ts";
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const MIN_DIGITS = 6;
const MAX_DIGITS = 10;
const MAX_WINDOW = 20;
function assertPeriod(period: number): number {
if (!Number.isInteger(period) || period <= 0 || period > 86_400) {
throw new RangeError("TOTP period must be an integer between 1 and 86400 seconds");
}
return period;
}
function assertDigits(digits: number): number {
if (!Number.isInteger(digits) || digits < MIN_DIGITS || digits > MAX_DIGITS) {
throw new RangeError(`TOTP digits must be an integer between ${MIN_DIGITS} and ${MAX_DIGITS}`);
}
return digits;
}
function assertTimestamp(timestamp: number): number {
if (!Number.isFinite(timestamp) || timestamp < 0) {
throw new RangeError("TOTP timestamp must be a finite non-negative number");
}
return timestamp;
}
function assertWindow(window: number): number {
if (!Number.isInteger(window) || window < 0 || window > MAX_WINDOW) {
throw new RangeError(`TOTP window must be an integer between 0 and ${MAX_WINDOW}`);
}
return window;
}
export function encodeBase32(bytes: Uint8Array): string {
let bits = 0;
let value = 0;
let output = "";
for (const byte of bytes) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) output += ALPHABET[(value << (5 - bits)) & 31];
return output;
}
export function decodeBase32(value: string): Uint8Array {
const compact = value.toUpperCase().replace(/[\s-]/g, "");
if (!compact || !/^[A-Z2-7]+={0,6}$/.test(compact)) {
throw new TypeError("Invalid base32 secret");
}
const firstPadding = compact.indexOf("=");
const normalized = firstPadding < 0 ? compact : compact.slice(0, firstPadding);
if (!normalized) throw new TypeError("Invalid base32 secret");
let bits = 0;
let buffer = 0;
const output: number[] = [];
for (const character of normalized) {
const index = ALPHABET.indexOf(character);
if (index < 0) throw new TypeError("Invalid base32 secret");
buffer = (buffer << 5) | index;
bits += 5;
if (bits >= 8) {
output.push((buffer >>> (bits - 8)) & 255);
bits -= 8;
}
}
if (!output.length) throw new TypeError("Invalid base32 secret");
return new Uint8Array(output);
}
function counterBytes(counter: number): Uint8Array {
if (!Number.isSafeInteger(counter) || counter < 0) {
throw new RangeError("HOTP counter must be a non-negative safe integer");
}
const bytes = new Uint8Array(8);
let value = BigInt(counter);
for (let index = 7; index >= 0; index -= 1) {
bytes[index] = Number(value & 255n);
value >>= 8n;
}
return bytes;
}
async function hotp(secret: string, counter: number, digits = 6): Promise<string> {
const normalizedDigits = assertDigits(digits);
const key = await crypto.subtle.importKey(
"raw",
decodeBase32(secret) as BufferSource,
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"],
);
const digest = new Uint8Array(
await crypto.subtle.sign("HMAC", key, counterBytes(counter) as BufferSource),
);
const offset = digest[digest.length - 1] & 15;
const binary =
((digest[offset] & 127) << 24) |
((digest[offset + 1] & 255) << 16) |
((digest[offset + 2] & 255) << 8) |
(digest[offset + 3] & 255);
return String(binary % 10 ** normalizedDigits).padStart(normalizedDigits, "0");
}
export interface TotpOptions {
period?: number;
digits?: number;
timestamp?: number;
}
export function generateTotpSecret(randomBytes?: (length: number) => Uint8Array): string {
const bytes = randomBytes ? randomBytes(20) : crypto.getRandomValues(new Uint8Array(20));
if (!(bytes instanceof Uint8Array) || bytes.length !== 20) {
throw new TypeError("TOTP random byte provider must return exactly 20 bytes");
}
return encodeBase32(bytes);
}
export async function generateTotp(secret: string, options: TotpOptions = {}): Promise<string> {
const period = assertPeriod(options.period ?? 30);
const timestamp = assertTimestamp(options.timestamp ?? Date.now());
const digits = assertDigits(options.digits ?? 6);
return hotp(secret, Math.floor(timestamp / 1000 / period), digits);
}
export async function verifyTotp(
secret: string,
token: string,
options: TotpOptions & { window?: number; lastCounter?: number } = {},
): Promise<{ valid: boolean; counter?: number }> {
const period = assertPeriod(options.period ?? 30);
const timestamp = assertTimestamp(options.timestamp ?? Date.now());
const digits = assertDigits(options.digits ?? 6);
const window = assertWindow(options.window ?? 1);
const normalizedToken = token.replace(/\s/g, "");
if (!new RegExp(`^\\d{${digits}}$`).test(normalizedToken)) return { valid: false };
const counter = Math.floor(timestamp / 1000 / period);
const lastCounter = options.lastCounter ?? -1;
if (!Number.isSafeInteger(lastCounter) || lastCounter < -1) {
throw new RangeError("TOTP lastCounter must be a safe integer greater than or equal to -1");
}
for (let offset = -window; offset <= window; offset += 1) {
const candidateCounter = counter + offset;
if (candidateCounter < 0 || candidateCounter <= lastCounter) continue;
const candidate = await hotp(secret, candidateCounter, digits);
if (await constantTimeEqual(candidate, normalizedToken)) {
return { valid: true, counter: candidateCounter };
}
}
return { valid: false };
}
export function totpUri(input: {
issuer: string;
accountName: string;
secret: string;
period?: number;
digits?: number;
}): string {
const issuer = input.issuer.trim();
const accountName = input.accountName.trim();
if (!issuer || !accountName) {
throw new TypeError("TOTP issuer and account name are required");
}
decodeBase32(input.secret);
const period = assertPeriod(input.period ?? 30);
const digits = assertDigits(input.digits ?? 6);
const label = encodeURIComponent(`${issuer}:${accountName}`);
const params = new URLSearchParams({
secret: input.secret.toUpperCase().replace(/[\s-]/g, "").replace(/=+$/g, ""),
issuer,
period: String(period),
digits: String(digits),
algorithm: "SHA1",
});
return `otpauth://totp/${label}?${params.toString()}`;
}
+461
View File
@@ -0,0 +1,461 @@
import type { Context } from "@wrnexus/core";
export type AuthIdentityType = "email" | "phone" | "username";
export type AuthTokenPurpose =
| "verify-email"
| "verify-phone"
| "password-reset"
| "magic-link"
| "invite"
| "change-email"
| "change-phone"
| "login-mfa";
export type AuthMfaMethod = "email-otp" | "sms-otp" | "totp" | "recovery-code" | "passkey";
export type AuthAccountStatus = "pending" | "active" | "locked" | "disabled" | "deleted";
export type AuthRiskLevel = "low" | "medium" | "high" | "critical";
export interface AuthUser {
id: string;
username?: string;
displayName?: string;
avatarUrl?: string;
status: AuthAccountStatus;
roles: string[];
emailVerified: boolean;
phoneVerified: boolean;
mfaEnabled: boolean;
locale?: string;
timezone?: string;
createdAt: number;
updatedAt: number;
lastLoginAt?: number;
metadata?: Record<string, unknown>;
}
export interface AuthIdentity {
id: string;
userId: string;
type: AuthIdentityType;
value: string;
normalizedValue: string;
primary: boolean;
verifiedAt?: number;
createdAt: number;
updatedAt: number;
}
export interface PasswordCredential {
userId: string;
passwordHash: string;
passwordVersion: number;
changedAt: number;
mustChange: boolean;
}
export interface AuthSession {
id: string;
userId: string;
deviceId: string;
createdAt: number;
lastSeenAt: number;
expiresAt: number;
absoluteExpiresAt: number;
ip?: string;
userAgent?: string;
trusted: boolean;
revokedAt?: number;
revokeReason?: string;
metadata?: Record<string, unknown>;
}
export interface TrustedDevice {
id: string;
userId: string;
name: string;
fingerprintHash: string;
createdAt: number;
lastSeenAt: number;
expiresAt: number;
revokedAt?: number;
}
export interface OneTimeToken {
id: string;
userId: string;
purpose: AuthTokenPurpose;
tokenHash: string;
target?: string;
createdAt: number;
expiresAt: number;
usedAt?: number;
attempts: number;
maxAttempts: number;
metadata?: Record<string, unknown>;
}
export interface OtpChallenge {
id: string;
userId: string;
method: "email-otp" | "sms-otp";
purpose: "verification" | "login" | "mfa";
destination: string;
codeHash: string;
createdAt: number;
expiresAt: number;
usedAt?: number;
attempts: number;
maxAttempts: number;
}
export interface TotpCredential {
id: string;
userId: string;
label: string;
secret: string;
createdAt: number;
verifiedAt?: number;
lastCounter?: number;
}
export interface RecoveryCodeRecord {
id: string;
userId: string;
codeHash: string;
createdAt: number;
usedAt?: number;
}
export interface PasskeyCredential {
id: string;
userId: string;
credentialId: string;
publicKey: string;
counter: number;
transports: string[];
name: string;
createdAt: number;
lastUsedAt?: number;
backedUp?: boolean;
deviceType?: string;
}
export interface OAuthAccount {
id: string;
userId: string;
provider: string;
providerAccountId: string;
email?: string;
accessToken?: string;
refreshToken?: string;
tokenExpiresAt?: number;
scope?: string;
createdAt: number;
updatedAt: number;
}
export interface LoginAttempt {
id: string;
identifier?: string;
userId?: string;
success: boolean;
reason?: string;
ip?: string;
userAgent?: string;
createdAt: number;
riskScore: number;
riskLevel: AuthRiskLevel;
}
export interface AuthSecurityEvent {
id: string;
userId?: string;
type: string;
severity: "info" | "warning" | "critical";
actorUserId?: string;
sessionId?: string;
ip?: string;
userAgent?: string;
createdAt: number;
data?: Record<string, unknown>;
}
export interface AuthRiskSignals {
failedAttempts?: number;
unfamiliarDevice?: boolean;
unusualIp?: boolean;
impossibleTravel?: boolean;
breachedPassword?: boolean;
automationSuspected?: boolean;
accountLocked?: boolean;
customScore?: number;
}
export interface AuthRiskDecision {
score: number;
level: AuthRiskLevel;
requireCaptcha: boolean;
requireMfa: boolean;
block: boolean;
reasons: string[];
}
export interface AuthPublicUser {
id: string;
username?: string;
displayName?: string;
avatarUrl?: string;
status: AuthAccountStatus;
roles: string[];
emailVerified: boolean;
phoneVerified: boolean;
mfaEnabled: boolean;
}
export interface AuthDeliveryMessage {
channel: "email" | "sms";
template:
| "verify-email"
| "verify-phone"
| "password-reset"
| "magic-link"
| "email-otp"
| "sms-otp"
| "login-alert"
| "invitation";
destination: string;
code?: string;
token?: string;
url?: string;
user: AuthPublicUser;
expiresAt: number;
data?: Record<string, unknown>;
}
export interface AuthDeliveryProvider {
send(message: AuthDeliveryMessage): Promise<void>;
}
/** HTTP response hook invoked after the auth engine establishes a signed-in session. */
export type AuthSignedInHandler = (ctx: Context, returnTo?: string) => Response | Promise<Response>;
/** HTTP response hook invoked after the auth engine clears a signed-in session. */
export type AuthSignedOutHandler = (ctx: Context) => Response | Promise<Response>;
export interface AuthSuccessfulSignUpAction {
/**
* Run the normal login policy with the newly registered credentials and
* establish a session when verification, CAPTCHA, MFA, and account policy
* allow it.
*/
autoSignIn?: boolean;
/** Same-origin path used after signup. Defaults to /account for auto sign-in and /sign-in otherwise. */
redirectTo?: string;
}
export type AuthSuccessfulSignUpHandler = (
ctx: Context,
user: AuthPublicUser,
) =>
| AuthSuccessfulSignUpAction
| Response
| void
| Promise<AuthSuccessfulSignUpAction | Response | void>;
/** Input used to build links placed in verification, recovery, magic-link, and invitation messages. */
export interface AuthTokenUrlInput {
purpose: AuthTokenPurpose;
token: string;
baseUrl?: string;
destination: string;
user: AuthPublicUser;
}
/** Encrypts sensitive authentication material before persistence. */
export interface AuthSecretProtector {
protect(value: string, purpose: "totp" | "oauth-access" | "oauth-refresh"): Promise<string>;
reveal(value: string, purpose: "totp" | "oauth-access" | "oauth-refresh"): Promise<string>;
}
export interface AuthImpersonationDecision {
allowed: boolean;
reason?: string;
}
export interface PasswordBreachProvider {
isBreached(password: string): Promise<boolean>;
}
export interface PasskeyRegistrationOptions {
challenge: string;
rp: { id: string; name: string };
user: { id: string; name: string; displayName: string };
timeout: number;
attestation: "none" | "direct" | "enterprise";
authenticatorSelection?: Record<string, unknown>;
excludeCredentials?: Array<{ id: string; type: "public-key"; transports?: string[] }>;
}
export interface PasskeyAuthenticationOptions {
challenge: string;
rpId: string;
timeout: number;
userVerification: "required" | "preferred" | "discouraged";
allowCredentials?: Array<{ id: string; type: "public-key"; transports?: string[] }>;
}
export interface PasskeyVerificationResult {
verified: boolean;
credential?: Omit<PasskeyCredential, "id" | "userId" | "createdAt">;
credentialId?: string;
newCounter?: number;
userId?: string;
error?: string;
}
export interface PasskeyProvider {
registrationOptions(input: {
user: AuthUser;
identities: AuthIdentity[];
credentials: PasskeyCredential[];
rpId: string;
rpName: string;
origin: string;
}): Promise<PasskeyRegistrationOptions>;
verifyRegistration(input: {
user: AuthUser;
response: unknown;
expectedChallenge: string;
expectedOrigin: string;
expectedRpId: string;
}): Promise<PasskeyVerificationResult>;
authenticationOptions(input: {
user?: AuthUser;
credentials: PasskeyCredential[];
rpId: string;
origin: string;
}): Promise<PasskeyAuthenticationOptions>;
verifyAuthentication(input: {
response: unknown;
credential?: PasskeyCredential;
expectedChallenge: string;
expectedOrigin: string;
expectedRpId: string;
}): Promise<PasskeyVerificationResult>;
}
export interface AuthClock {
now(): number;
}
export interface AuthRandom {
bytes(length: number): Uint8Array;
}
export interface AuthEngineOptions {
store: import("./store.ts").AuthStore;
secret: string;
issuer?: string;
delivery?: AuthDeliveryProvider;
/**
* Customize the HTTP response after any package sign-in flow succeeds.
* Keeping this beside delivery and tokenUrl makes the engine the single
* location for authentication behavior.
*/
onSignedIn?: AuthSignedInHandler;
/** Customize the HTTP response after a package logout succeeds. */
onSignedOut?: AuthSignedOutHandler;
/**
* Customize successful package registration. Without this hook, signup
* redirects to /sign-in.
*/
onSuccessfulSignUp?: AuthSuccessfulSignUpHandler;
/** @deprecated Misspelled alias; use onSuccessfulSignUp. */
onSuccessfullSignUp?: AuthSuccessfulSignUpHandler;
breachProvider?: PasswordBreachProvider;
passkeys?: PasskeyProvider;
passkeyChallengeStore?: import("./passkeys/index.ts").PasskeyChallengeStore;
clock?: AuthClock;
random?: AuthRandom;
sessionTtlMs?: number;
sessionAbsoluteTtlMs?: number;
trustedDeviceTtlMs?: number;
tokenTtlMs?: Partial<Record<AuthTokenPurpose, number>>;
/**
* Customize action links delivered with one-time tokens. Returning undefined
* intentionally omits the URL while still delivering the raw token.
*/
tokenUrl?: (input: AuthTokenUrlInput) => string | undefined;
otpTtlMs?: number;
maxTokenAttempts?: number;
maxOtpAttempts?: number;
maxFailedLogins?: number;
lockDurationMs?: number;
passwordMinLength?: number;
requireVerifiedEmail?: boolean;
requireVerifiedPhone?: boolean;
captchaThreshold?: number;
mfaThreshold?: number;
blockThreshold?: number;
skipMfaForTrustedDevices?: boolean;
sendLoginAlerts?: boolean;
secretProtector?: AuthSecretProtector;
linkVerifiedOAuthEmails?: boolean;
isOAuthEmailVerified?: (
provider: string,
profile: import("@wrnexus/oauth").OAuthProfile,
) => boolean | Promise<boolean>;
authorizeImpersonation?: (input: {
actor: AuthUser;
target: AuthUser;
reason?: string;
}) => boolean | AuthImpersonationDecision | Promise<boolean | AuthImpersonationDecision>;
audit?: (event: AuthSecurityEvent) => void | Promise<void>;
}
export interface RegisterInput {
email?: string;
phone?: string;
username?: string;
password: string;
displayName?: string;
locale?: string;
timezone?: string;
metadata?: Record<string, unknown>;
}
export interface LoginInput {
identifier: string;
password: string;
ip?: string;
userAgent?: string;
deviceId?: string;
deviceName?: string;
fingerprint?: string;
rememberDevice?: boolean;
captchaVerified?: boolean;
signals?: AuthRiskSignals;
}
export interface AuthResult {
ok: boolean;
code?: string;
message?: string;
user?: AuthPublicUser;
session?: AuthSession;
risk?: AuthRiskDecision;
mfaToken?: string;
requires?: {
captcha?: boolean;
mfa?: AuthMfaMethod[];
emailVerification?: boolean;
phoneVerification?: boolean;
};
}
export interface AuthenticatedContext extends Context {
user: AuthPublicUser;
locals: Context["locals"] & {
authUser: AuthPublicUser;
authSession?: AuthSession;
};
}
+355
View File
@@ -0,0 +1,355 @@
import { v, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation";
const strongPassword = () =>
v
.string()
.required("Enter your password")
.min(12, "Password must be at least 12 characters")
.max(256, "Password must be at most 256 characters")
.pattern(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/,
"Password must include uppercase, lowercase, and a number",
);
/** Generic server-side registration input for custom registration experiences. */
export const registerSchema = v.object({
displayName: v.string().trim().min(2, "Enter your full name").max(120),
email: v.string().trim().email("Enter a valid email address").optional(),
phone: v.string().trim().min(7, "Enter a valid phone number").max(24).optional(),
username: v
.string()
.trim()
.min(3, "Username must be at least 3 characters")
.max(64)
.pattern(/^[a-zA-Z0-9._-]+$/, "Use only letters, numbers, dots, underscores, or hyphens")
.optional(),
password: strongPassword(),
locale: v.string().max(32).optional(),
timezone: v.string().max(64).optional(),
});
/** Default schema shared by the packaged SignUp component and register route. */
export const signUpSchema = registerSchema.extend({
email: v
.string()
.trim()
.required("Enter your email address")
.email("Enter a valid email address"),
consent: v.boolean().required("Accept the terms and privacy policy to continue"),
});
export const loginSchema = v.object({
identifier: v.string().trim().required("Enter your email, phone, or username").max(320),
password: v.string().required("Enter your password").max(256),
returnTo: v.string().max(2048).optional(),
rememberDevice: v.boolean().optional(),
deviceFingerprint: v.string().max(512).optional(),
deviceName: v.string().max(120).optional(),
});
export const verificationRequestSchema = v.object({
type: v
.string()
.required("Choose email or phone verification")
.oneOf(["email", "phone"], "Choose email or phone verification"),
identifier: v.string().trim().max(320).optional(),
});
export const verificationTokenSchema = v.object({
token: v
.string()
.trim()
.required("Enter the verification token")
.min(6, "Verification token is too short")
.max(512),
});
export const passwordResetRequestSchema = v.object({
identifier: v
.string()
.trim()
.required("Enter your email, phone, or username")
.max(320, "Account identifier is too long"),
});
export const passwordResetSchema = v.object({
token: v
.string()
.required("Password reset token is missing")
.min(20, "Password reset token is invalid")
.max(512),
password: strongPassword(),
});
export const invitationAcceptSchema = v.object({
token: v
.string()
.required("Invitation token is missing")
.min(20, "Invitation token is invalid")
.max(512),
displayName: v.string().trim().min(2, "Enter your full name").max(120).optional(),
password: strongPassword().optional(),
});
export const magicLinkRequestSchema = v.object({
identifier: v.string().trim().required("Enter your email, phone, or username").max(320),
});
export const magicLinkConsumeSchema = v.object({
token: v
.string()
.required("Magic-link token is missing")
.min(20, "Magic-link token is invalid")
.max(512),
returnTo: v.string().max(2048).optional(),
});
export const otpLoginRequestSchema = v.object({
identifier: v.string().trim().required("Enter your email, phone, or username").max(320),
method: v
.string()
.required("Choose an OTP delivery method")
.oneOf(["email-otp", "sms-otp"], "Choose email or SMS OTP"),
});
export const otpLoginCompleteSchema = v.object({
challengeId: v
.string()
.required("OTP challenge is missing")
.min(8, "OTP challenge is invalid")
.max(191),
code: v
.string()
.trim()
.required("Enter the one-time code")
.pattern(/^\d{6}$/, "Enter the six-digit one-time code"),
returnTo: v.string().max(2048).optional(),
});
export const otpIssueSchema = v.object({
method: v
.string()
.required("Choose an OTP delivery method")
.oneOf(["email-otp", "sms-otp"], "Choose email or SMS OTP"),
destination: v.string().trim().max(320).optional(),
});
export const otpSchema = v.object({
challengeId: v
.string()
.required("OTP challenge is missing")
.min(8, "OTP challenge is invalid")
.max(191),
code: v
.string()
.trim()
.required("Enter the one-time code")
.pattern(/^\d{6}$/, "Enter the six-digit one-time code"),
});
export const mfaOtpRequestSchema = v.object({
mfaToken: v
.string()
.required("MFA transaction is missing")
.min(20, "MFA transaction is invalid")
.max(512),
method: v
.string()
.required("Choose an MFA delivery method")
.oneOf(["email-otp", "sms-otp"], "Choose email or SMS OTP"),
});
export const mfaSchema = v.object({
mfaToken: v
.string()
.required("MFA transaction is missing")
.min(20, "MFA transaction is invalid")
.max(512),
method: v
.string()
.required("Choose a verification method")
.oneOf(
["totp", "recovery-code", "email-otp", "sms-otp"],
"Choose a supported verification method",
),
challengeId: v.string().max(191).optional(),
code: v
.string()
.trim()
.required("Enter the verification code")
.min(6, "Verification code is too short")
.max(32),
returnTo: v.string().max(2048).optional(),
});
export const sessionRevokeSchema = v.object({
sessionId: v.string().required("Session ID is missing").min(3).max(191),
});
export const impersonationStartSchema = v.object({
targetUserId: v.string().required("Choose a user to impersonate").min(3).max(191),
reason: v.string().trim().max(500).optional(),
});
export const passkeyRegistrationOptionsSchema = v.object({
rpId: v.string().trim().max(253).optional(),
rpName: v.string().trim().max(120).optional(),
origin: v.string().trim().url("Enter a valid origin").optional(),
});
export const passkeyRegistrationVerifySchema = v.object({
key: v.string().required("Passkey challenge key is missing").min(8).max(512),
response: v.unknown().required("Passkey response is missing"),
rpId: v.string().trim().max(253).optional(),
origin: v.string().trim().url("Enter a valid origin").optional(),
name: v.string().trim().max(120).optional(),
});
export const passkeyAuthenticationOptionsSchema = v.object({
identifier: v.string().trim().max(320).optional(),
rpId: v.string().trim().max(253).optional(),
origin: v.string().trim().url("Enter a valid origin").optional(),
});
export const passkeyAuthenticationVerifySchema = v.object({
key: v.string().required("Passkey challenge key is missing").min(8).max(512),
response: v.unknown().required("Passkey response is missing"),
rpId: v.string().trim().max(253).optional(),
origin: v.string().trim().url("Enter a valid origin").optional(),
});
export const authenticatorSetupSchema = v.object({
label: v.string().trim().max(120).optional(),
});
export const authenticatorConfirmSchema = v.object({
credentialId: v.string().required("Authenticator credential is missing").min(3).max(191),
code: v
.string()
.trim()
.required("Enter the authenticator code")
.pattern(/^\d{6}$/, "Enter the six-digit authenticator code"),
});
export const authenticatorDisableSchema = v.object({
credentialId: v.string().required("Authenticator credential is missing").min(3).max(191),
});
export const recoveryCodesSchema = v.object({
count: v.number().integer("Recovery code count must be a whole number").min(1).max(50).optional(),
});
export const emptyActionSchema = v.object({});
export const changePasswordSchema = v.object({
currentPassword: v.string().required("Enter your current password").max(256),
nextPassword: strongPassword(),
});
export interface AuthSchemaSet {
register: ObjectSchema;
signUp: ObjectSchema;
login: ObjectSchema;
verificationRequest: ObjectSchema;
verificationToken: ObjectSchema;
passwordResetRequest: ObjectSchema;
passwordReset: ObjectSchema;
invitationAccept: ObjectSchema;
magicLinkRequest: ObjectSchema;
magicLinkConsume: ObjectSchema;
otpLoginRequest: ObjectSchema;
otpLoginComplete: ObjectSchema;
otpIssue: ObjectSchema;
otpVerify: ObjectSchema;
mfaOtpRequest: ObjectSchema;
mfaComplete: ObjectSchema;
sessionRevoke: ObjectSchema;
impersonationStart: ObjectSchema;
passkeyRegistrationOptions: ObjectSchema;
passkeyRegistrationVerify: ObjectSchema;
passkeyAuthenticationOptions: ObjectSchema;
passkeyAuthenticationVerify: ObjectSchema;
authenticatorSetup: ObjectSchema;
authenticatorConfirm: ObjectSchema;
authenticatorDisable: ObjectSchema;
recoveryCodes: ObjectSchema;
emptyAction: ObjectSchema;
changePassword: ObjectSchema;
}
export type AuthSchemaOverrides = Partial<AuthSchemaSet>;
export const authSchemas: AuthSchemaSet = {
register: signUpSchema,
signUp: signUpSchema,
login: loginSchema,
verificationRequest: verificationRequestSchema,
verificationToken: verificationTokenSchema,
passwordResetRequest: passwordResetRequestSchema,
passwordReset: passwordResetSchema,
invitationAccept: invitationAcceptSchema,
magicLinkRequest: magicLinkRequestSchema,
magicLinkConsume: magicLinkConsumeSchema,
otpLoginRequest: otpLoginRequestSchema,
otpLoginComplete: otpLoginCompleteSchema,
otpIssue: otpIssueSchema,
otpVerify: otpSchema,
mfaOtpRequest: mfaOtpRequestSchema,
mfaComplete: mfaSchema,
sessionRevoke: sessionRevokeSchema,
impersonationStart: impersonationStartSchema,
passkeyRegistrationOptions: passkeyRegistrationOptionsSchema,
passkeyRegistrationVerify: passkeyRegistrationVerifySchema,
passkeyAuthenticationOptions: passkeyAuthenticationOptionsSchema,
passkeyAuthenticationVerify: passkeyAuthenticationVerifySchema,
authenticatorSetup: authenticatorSetupSchema,
authenticatorConfirm: authenticatorConfirmSchema,
authenticatorDisable: authenticatorDisableSchema,
recoveryCodes: recoveryCodesSchema,
emptyAction: emptyActionSchema,
changePassword: changePasswordSchema,
};
export function resolveAuthSchemas(overrides: AuthSchemaOverrides = {}): AuthSchemaSet {
return { ...authSchemas, ...overrides };
}
export const authBrowserSchemaMap = {
"auth-register": "register",
"auth-login": "login",
"auth-verification-request": "verificationRequest",
"auth-verification-token": "verificationToken",
"auth-password-request": "passwordResetRequest",
"auth-password-reset": "passwordReset",
"auth-invitation": "invitationAccept",
"auth-magic-link-request": "magicLinkRequest",
"auth-magic-link-consume": "magicLinkConsume",
"auth-otp-login-request": "otpLoginRequest",
"auth-otp-login-complete": "otpLoginComplete",
"auth-otp-issue": "otpIssue",
"auth-otp": "otpVerify",
"auth-mfa-otp-request": "mfaOtpRequest",
"auth-mfa": "mfaComplete",
"auth-session-revoke": "sessionRevoke",
"auth-impersonation-start": "impersonationStart",
"auth-passkey-registration-options": "passkeyRegistrationOptions",
"auth-passkey-registration-verify": "passkeyRegistrationVerify",
"auth-passkey-authentication-options": "passkeyAuthenticationOptions",
"auth-passkey-authentication-verify": "passkeyAuthenticationVerify",
"auth-authenticator-setup": "authenticatorSetup",
"auth-authenticator-confirm": "authenticatorConfirm",
"auth-authenticator-disable": "authenticatorDisable",
"auth-recovery-codes": "recoveryCodes",
"auth-empty": "emptyAction",
"auth-change-password": "changePassword",
} as const satisfies Record<string, keyof AuthSchemaSet>;
export function authBrowserSchemaDescriptors(
schemas: AuthSchemaSet = authSchemas,
): Record<string, SchemaDescriptor> {
const descriptors: Record<string, SchemaDescriptor> = {};
for (const [browserName, schemaName] of Object.entries(authBrowserSchemaMap)) {
descriptors[browserName] = schemas[schemaName].describe();
}
return descriptors;
}
+68
View File
@@ -0,0 +1,68 @@
import { expect, test } from "bun:test";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
const directory = join(import.meta.dir, "..", "components");
test("all authentication components use theme tokens and Tailwind utilities", () => {
const files = readdirSync(directory).filter((file) => file.endsWith(".wrn"));
expect(files.length).toBeGreaterThanOrEqual(16);
for (const file of files) {
const source = readFileSync(join(directory, file), "utf8");
expect(source).toContain("component ");
expect(source).toContain("--wire-");
expect(source).not.toContain("<style");
}
});
test("passkey component declares the automatic package runtime", () => {
const source = readFileSync(join(directory, "PasskeyButton.wrn"), "utf8");
expect(source).toContain('data-wrnexus-runtime="auth"');
expect(source).not.toContain("<script");
});
test("SignIn exposes a slot for CAPTCHA and application-specific controls", () => {
const source = readFileSync(join(directory, "SignIn.wrn"), "utf8");
expect(source).toContain("<slot></slot>");
expect(source.indexOf("<slot></slot>")).toBeLessThan(source.indexOf('data-error="_form"'));
});
test("packaged auth forms use built-in schemas instead of native browser validation", () => {
const files = readdirSync(directory).filter((file) => file.endsWith(".wrn"));
for (const file of files) {
const source = readFileSync(join(directory, file), "utf8");
if (!source.includes("<form")) continue;
expect(source).not.toContain(" required");
for (const form of source.matchAll(/<form[\s\S]*?>/g)) {
if (!form[0].includes("data-schema")) continue;
expect(form[0]).toContain("novalidate");
}
}
});
test("passwordless and passkey components preserve MFA continuation metadata", () => {
const otp = readFileSync(join(directory, "OtpSignIn.wrn"), "utf8");
const passkey = readFileSync(join(directory, "PasskeyButton.wrn"), "utf8");
const signIn = readFileSync(join(directory, "SignIn.wrn"), "utf8");
expect(otp).toContain("data-mfa-href");
expect(passkey).toContain("data-mfa-href");
expect(signIn).toContain("mfaHref='{mfaHref}'");
expect(signIn).toContain('name="deviceFingerprint"');
expect(signIn).toContain('name="deviceName"');
});
test("verification components can resend without application-owned schema files", () => {
const email = readFileSync(join(directory, "VerifyEmail.wrn"), "utf8");
const phone = readFileSync(join(directory, "VerifyPhone.wrn"), "utf8");
for (const source of [email, phone]) {
expect(source).toContain('name="identifier"');
expect(source).toContain("auth-verification-request");
expect(source).toContain("novalidate");
}
});
test("auth browser runtime refuses cross-origin navigation targets", () => {
const source = readFileSync(join(directory, "..", "assets", "client", "auth.js"), "utf8");
expect(source).toContain("if (url.origin !== location.origin) return false");
expect(source).toContain("!window.AbortController");
});
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from "bun:test";
import { base64UrlToBytes, bytesToBase64Url, randomDigits, randomToken } from "../src/crypto.ts";
test("base64url helpers round-trip canonical values and reject malformed input", () => {
const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]);
const encoded = bytesToBase64Url(bytes);
expect(base64UrlToBytes(encoded)).toEqual(bytes);
expect(() => base64UrlToBytes("a===")).toThrow("Invalid base64url value");
expect(() => base64UrlToBytes("a")).toThrow("Invalid base64url value");
});
test("random helpers reject broken random providers instead of looping forever", () => {
expect(() => randomToken(() => new Uint8Array(1), 32)).toThrow("exactly 32 bytes");
expect(() => randomDigits((length) => new Uint8Array(length).fill(255), 6)).toThrow(
"WRN-AUTH-RANDOM-SOURCE-REJECTED",
);
});
+861
View File
@@ -0,0 +1,861 @@
import { describe, expect, test } from "bun:test";
import { createAuthEngine } from "../src/engine.ts";
import { MemoryAuthStore } from "../src/stores/memory.ts";
import { generateTotp } from "../src/totp/index.ts";
import type { AuthDeliveryMessage, PasskeyProvider } from "../src/types.ts";
function fixture() {
let time = 1_720_000_000_000;
let seed = 11;
const messages: AuthDeliveryMessage[] = [];
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "test-auth-secret-that-is-longer-than-thirty-two-characters",
issuer: "WRNexus Test",
clock: { now: () => time },
random: {
bytes(length) {
const bytes = new Uint8Array(length);
for (let index = 0; index < length; index += 1) {
seed = (seed * 1664525 + 1013904223) >>> 0;
bytes[index] = seed & 255;
}
return bytes;
},
},
delivery: {
async send(message) {
messages.push(message);
},
},
});
return {
engine,
messages,
advance(ms: number) {
time += ms;
},
now: () => time,
};
}
describe("authentication engine", () => {
test("rejects unsafe numeric authentication configuration", () => {
expect(() =>
createAuthEngine({
store: new MemoryAuthStore(),
secret: "invalid-config-secret-that-is-longer-than-thirty-two-characters",
sessionTtlMs: Number.NaN,
}),
).toThrow("sessionTtlMs");
expect(() =>
createAuthEngine({
store: new MemoryAuthStore(),
secret: "invalid-config-secret-that-is-longer-than-thirty-two-characters",
passwordMinLength: 4,
}),
).toThrow("passwordMinLength");
});
test("registers, verifies email, signs in, and validates the session", async () => {
const { engine, messages } = fixture();
const registered = await engine.register({
email: "Owner@Example.com",
username: "owner",
displayName: "Owner",
password: "StrongPassword123",
});
expect(registered.ok).toBe(true);
expect(messages[0]?.template).toBe("verify-email");
const verified = await engine.verifyEmail(messages[0]!.token!);
expect(verified).toMatchObject({ ok: true, user: { emailVerified: true } });
const login = await engine.login({
identifier: "owner@example.com",
password: "StrongPassword123",
});
expect(login.ok).toBe(true);
expect(login.session).toBeDefined();
expect(await engine.validateSession(login.session!.id)).toMatchObject({
userId: registered.user!.id,
});
});
test("delivery provider failures do not corrupt completed authentication state", async () => {
const store = new MemoryAuthStore();
const engine = createAuthEngine({
store,
secret: "delivery-failure-secret-that-is-longer-than-thirty-two-characters",
sendLoginAlerts: true,
delivery: {
async send() {
throw new Error("provider unavailable");
},
},
});
const registered = await engine.register({
email: "delivery-failure@example.com",
password: "StrongPassword123",
});
expect(registered.ok).toBe(true);
const user = await store.findUserById(registered.user!.id);
user!.status = "active";
user!.emailVerified = true;
await store.updateUser(user!);
const login = await engine.login({
identifier: "delivery-failure@example.com",
password: "StrongPassword123",
});
expect(login.ok).toBe(true);
expect(
(await store.listSecurityEvents(registered.user!.id)).some(
(event) => event.type === "delivery.failed",
),
).toBe(true);
});
test("engine-level registration rejects malformed identities before persistence", async () => {
const { engine } = fixture();
expect(
await engine.register({ email: "not-an-email", password: "StrongPassword123" }),
).toMatchObject({ ok: false, code: "registration-failed" });
expect(await engine.register({ phone: "+12", password: "StrongPassword123" })).toMatchObject({
ok: false,
code: "registration-failed",
});
expect(await engine.store.listUsers()).toHaveLength(0);
});
test("normalizes identities and rejects duplicates", async () => {
const { engine } = fixture();
expect(
(await engine.register({ email: "One@Example.com", password: "StrongPassword123" })).ok,
).toBe(true);
const duplicate = await engine.register({
email: "one@example.com",
password: "AnotherStrong123",
});
expect(duplicate).toMatchObject({ ok: false, code: "registration-failed" });
});
test("issues and consumes password reset links once", async () => {
const { engine, messages } = fixture();
await engine.register({ email: "reset@example.com", password: "StrongPassword123" });
await engine.requestPasswordReset("reset@example.com", "https://example.test");
const token = messages.find((message) => message.template === "password-reset")!.token!;
expect((await engine.resetPassword(token, "NewStrongPassword456")).ok).toBe(true);
expect((await engine.resetPassword(token, "OtherStrongPassword789")).ok).toBe(false);
expect(
(await engine.login({ identifier: "reset@example.com", password: "NewStrongPassword456" }))
.ok,
).toBe(true);
});
test("supports email OTP verification with attempt and expiry protection", async () => {
const { engine, messages, advance } = fixture();
const registered = await engine.register({
email: "otp@example.com",
password: "StrongPassword123",
});
const challenge = await engine.issueOtp(registered.user!.id, "email-otp");
const code = messages.find((message) => message.template === "email-otp")!.code!;
expect((await engine.verifyOtp(challenge.id, "000000")).ok).toBe(false);
expect((await engine.verifyOtp(challenge.id, `${code.slice(0, 3)}-${code.slice(3)}`)).ok).toBe(
false,
);
expect((await engine.verifyOtp(challenge.id, code)).ok).toBe(true);
expect((await engine.verifyOtp(challenge.id, code)).ok).toBe(false);
const expired = await engine.issueOtp(registered.user!.id, "email-otp");
advance(11 * 60_000);
expect((await engine.verifyOtp(expired.id, messages.at(-1)!.code!)).ok).toBe(false);
});
test("binds OTPs to linked destinations and to their intended flow", async () => {
const { engine, messages } = fixture();
const registered = await engine.register({
email: "bound@example.com",
password: "StrongPassword123",
});
let rejected = false;
try {
await engine.issueOtp(registered.user!.id, "email-otp", "attacker@example.com");
} catch {
rejected = true;
}
expect(rejected).toBe(true);
const loginChallenge = await engine.requestOtpLogin("bound@example.com", "email-otp");
const code = messages.find((message) => message.template === "email-otp")!.code!;
expect((await engine.verifyOtp(loginChallenge!.id, code)).ok).toBe(false);
expect((await engine.completeOtpLogin(loginChallenge!.id, code)).ok).toBe(true);
});
test("returns an opaque OTP challenge for an unknown account", async () => {
const { engine, messages } = fixture();
const challenge = await engine.requestOtpLogin("missing@example.com", "email-otp");
expect(challenge?.id.startsWith("otp_")).toBe(true);
expect(messages).toHaveLength(0);
expect((await engine.completeOtpLogin(challenge!.id, "123456")).ok).toBe(false);
});
test("enables replay-safe TOTP and one-use recovery codes", async () => {
const { engine, now } = fixture();
const registered = await engine.register({
email: "mfa@example.com",
password: "StrongPassword123",
});
const setup = await engine.beginTotp(registered.user!.id, "Primary authenticator");
const token = await generateTotp(setup.secret, { timestamp: now() });
expect(await engine.confirmTotp(registered.user!.id, setup.credentialId, token)).toBe(true);
const next = await generateTotp(setup.secret, { timestamp: now() + 30_000 });
expect(await engine.verifyTotp(registered.user!.id, next)).toBe(true);
expect(await engine.verifyTotp(registered.user!.id, next)).toBe(false);
const codes = await engine.generateRecoveryCodes(registered.user!.id, 3);
expect(codes).toHaveLength(3);
expect(await engine.consumeRecoveryCode(registered.user!.id, codes[0]!)).toBe(true);
expect(await engine.consumeRecoveryCode(registered.user!.id, codes[0]!)).toBe(false);
expect(await engine.listRecoveryCodeStatus(registered.user!.id)).toEqual({
total: 3,
remaining: 2,
});
const replacement = await engine.generateRecoveryCodes(registered.user!.id, 2);
expect(await engine.consumeRecoveryCode(registered.user!.id, codes[1]!)).toBe(false);
expect(await engine.consumeRecoveryCode(registered.user!.id, replacement[0]!)).toBe(true);
});
test("creates a trusted session when remember-device is requested", async () => {
const { engine } = fixture();
await engine.register({ email: "remember@example.com", password: "StrongPassword123" });
const result = await engine.login({
identifier: "remember@example.com",
password: "StrongPassword123",
fingerprint: "browser-fingerprint",
deviceName: "Work browser",
rememberDevice: true,
});
expect(result).toMatchObject({ ok: true, session: { trusted: true } });
});
test("tracks and revokes sessions and trusted devices", async () => {
const { engine } = fixture();
const registered = await engine.register({
email: "devices@example.com",
password: "StrongPassword123",
});
const device = await engine.trustDevice(registered.user!.id, {
fingerprint: "browser-device",
name: "Work laptop",
});
expect(device.name).toBe("Work laptop");
const session = await engine.createSession(registered.user!.id, {
fingerprint: "browser-device",
});
expect(session.trusted).toBe(true);
expect(await engine.revokeSession(registered.user!.id, session.id)).toBe(true);
expect(await engine.validateSession(session.id)).toBeUndefined();
});
test("lists only active sessions and removes expired records", async () => {
const { engine, advance } = fixture();
const registered = await engine.register({
email: "active-sessions@example.com",
password: "StrongPassword123",
});
const expired = await engine.createSession(registered.user!.id);
advance(25 * 60 * 60_000);
const active = await engine.createSession(registered.user!.id);
expect(await engine.listSessions(registered.user!.id)).toEqual([active]);
expect(await engine.store.findSession(expired.id)).toBeUndefined();
});
test("does not reveal account status before password verification", async () => {
const { engine } = fixture();
const registered = await engine.register({
email: "disabled-login@example.com",
password: "StrongPassword123",
});
await engine.setAccountStatus(registered.user!.id, "disabled");
const wrongPassword = await engine.login({
identifier: "disabled-login@example.com",
password: "WrongPassword999",
});
const unknownAccount = await engine.login({
identifier: "missing-login@example.com",
password: "WrongPassword999",
});
expect(wrongPassword).toMatchObject({
ok: false,
code: "invalid-credentials",
message: "The email, phone, username, or password you entered is incorrect.",
});
expect(unknownAccount).toMatchObject({
ok: false,
code: "invalid-credentials",
message: wrongPassword.message,
});
expect(
await engine.login({
identifier: "disabled-login@example.com",
password: "StrongPassword123",
}),
).toMatchObject({
ok: false,
code: "account-disabled",
message: "This account is disabled. Contact support for help.",
});
});
test("raises CAPTCHA and blocks repeated suspicious sign-in attempts", async () => {
const { engine } = fixture();
await engine.register({ email: "risk@example.com", password: "StrongPassword123" });
for (let index = 0; index < 4; index += 1) {
await engine.login({
identifier: "risk@example.com",
password: "WrongPassword999",
captchaVerified: true,
});
}
const captcha = await engine.login({
identifier: "risk@example.com",
password: "WrongPassword999",
});
expect(captcha).toMatchObject({
ok: false,
code: "captcha-required",
requires: { captcha: true },
});
await engine.login({
identifier: "risk@example.com",
password: "WrongPassword999",
captchaVerified: true,
});
const blocked = await engine.login({
identifier: "risk@example.com",
password: "WrongPassword999",
captchaVerified: true,
});
expect(blocked.ok).toBe(false);
expect(blocked.risk?.block).toBe(true);
});
test("links OAuth accounts and creates users for new provider identities", async () => {
const { engine } = fixture();
const result = await engine.loginWithOAuth(" GitHub ", {
id: "github-123",
email: "oauth@example.com",
name: "OAuth User",
raw: { email_verified: true },
});
expect(result.ok).toBe(true);
expect(result.user?.emailVerified).toBe(true);
const accounts = await engine.store.listOAuthAccounts(result.user!.id);
expect(accounts).toHaveLength(1);
expect(accounts[0]?.provider).toBe("github");
});
test("creates and accepts a single-use invitation", async () => {
const { engine, messages } = fixture();
const invitation = await engine.createInvitation({
email: "invited@example.com",
displayName: "Invited User",
roles: ["member"],
invitedBy: "admin-user",
baseUrl: "https://example.test",
});
expect(messages.at(-1)?.template).toBe("invitation");
const accepted = await engine.acceptInvitation(invitation.token, {
password: "StrongPassword123",
});
expect(accepted).toMatchObject({ ok: true, user: { emailVerified: true, status: "active" } });
expect(accepted.user?.roles).toContain("member");
expect(
(await engine.acceptInvitation(invitation.token, { password: "StrongPassword123" })).ok,
).toBe(false);
});
test("customizes delivered token links without application-owned auth APIs", async () => {
const messages: AuthDeliveryMessage[] = [];
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "token-url-test-secret-that-is-longer-than-thirty-two-characters",
delivery: {
async send(message) {
messages.push(message);
},
},
tokenUrl({ purpose, token, baseUrl }) {
if (!baseUrl) return undefined;
const path =
purpose === "password-reset"
? `/recover/reset?token=${encodeURIComponent(token)}`
: purpose === "invite"
? `/invitation?token=${encodeURIComponent(token)}`
: `/${purpose}?token=${encodeURIComponent(token)}`;
return new URL(path, baseUrl).toString();
},
});
await engine.register({
email: "links@example.com",
password: "StrongPassword123",
});
messages.length = 0;
await engine.requestPasswordReset("links@example.com", "https://example.test");
expect(messages.at(-1)?.url?.startsWith("https://example.test/recover/reset?token=")).toBe(
true,
);
await engine.createInvitation({
email: "invited-links@example.com",
baseUrl: "https://example.test",
});
expect(messages.at(-1)?.url?.startsWith("https://example.test/invitation?token=")).toBe(true);
});
test("supports passwordless OTP login and sends a login alert", async () => {
const { engine, messages } = fixture();
await engine.register({ email: "passwordless@example.com", password: "StrongPassword123" });
const challenge = await engine.requestOtpLogin("passwordless@example.com", "email-otp");
const code = messages.find((message) => message.template === "email-otp")!.code!;
const result = await engine.completeOtpLogin(challenge!.id, code);
expect(result).toMatchObject({ ok: true, user: { emailVerified: true } });
expect(result.session).toBeDefined();
});
test("protects TOTP and OAuth secrets before persistence", async () => {
const store = new MemoryAuthStore();
const protectedValues: string[] = [];
const engine = createAuthEngine({
store,
secret: "protector-test-secret-that-is-longer-than-thirty-two-characters",
secretProtector: {
async protect(value, purpose) {
const output = `sealed:${purpose}:${value}`;
protectedValues.push(output);
return output;
},
async reveal(value) {
return value.split(":").slice(2).join(":");
},
},
});
const registered = await engine.register({
email: "protected@example.com",
password: "StrongPassword123",
});
const setup = await engine.beginTotp(registered.user!.id);
expect((await store.listTotp(registered.user!.id))[0]?.secret.startsWith("sealed:totp:")).toBe(
true,
);
await engine.linkOAuth(
registered.user!.id,
"example",
{ id: "provider-id", raw: {} },
{
access_token: "access",
refresh_token: "refresh",
token_type: "Bearer",
},
);
expect(protectedValues.some((value) => value.startsWith("sealed:oauth-access:"))).toBe(true);
expect(setup.secret.startsWith("sealed:")).toBe(false);
});
test("requires an explicit policy and audits impersonation sessions", async () => {
const store = new MemoryAuthStore();
const engine = createAuthEngine({
store,
secret: "impersonation-secret-that-is-longer-than-thirty-two-characters",
authorizeImpersonation: ({ actor }) => actor.roles.includes("admin"),
});
const actorResult = await engine.register({
email: "admin@example.com",
password: "StrongPassword123",
});
const targetResult = await engine.register({
email: "target@example.com",
password: "StrongPassword123",
});
const actor = await store.findUserById(actorResult.user!.id);
const target = await store.findUserById(targetResult.user!.id);
actor!.roles = ["admin"];
actor!.status = "active";
target!.status = "active";
await store.updateUser(actor!);
await store.updateUser(target!);
const actorSession = await engine.createSession(actor!.id);
const started = await engine.startImpersonation(actor!.id, target!.id, {
sessionId: actorSession.id,
reason: "Support case",
});
expect(started).toMatchObject({ ok: true, user: { id: target!.id } });
expect(started.session?.metadata).toMatchObject({ impersonated: true, actorUserId: actor!.id });
const stopped = await engine.stopImpersonation(started.session!.id);
expect(stopped).toMatchObject({
ok: true,
user: { id: actor!.id },
session: { id: actorSession.id },
});
});
test("does not auto-link an unverified OAuth email to an existing account", async () => {
const { engine } = fixture();
await engine.register({ email: "existing@example.com", password: "StrongPassword123" });
const result = await engine.loginWithOAuth("unknown", {
id: "provider-unverified",
email: "existing@example.com",
raw: { email_verified: false },
});
expect(result).toMatchObject({ ok: false, code: "oauth-link-required" });
});
test("uses a configured passkey provider for registration and authentication", async () => {
let expectedOrigin = "";
let expectedRpId = "";
const provider: PasskeyProvider = {
async registrationOptions() {
return {
challenge: "AQID",
rp: { id: "example.test", name: "Test" },
user: { id: "AQID", name: "user", displayName: "User" },
timeout: 1,
attestation: "none",
};
},
async verifyRegistration() {
return {
verified: true,
credential: {
credentialId: "cred-1",
publicKey: "public",
counter: 0,
transports: ["internal"],
name: "Device passkey",
},
};
},
async authenticationOptions() {
return {
challenge: "BAUG",
rpId: "example.test",
timeout: Number.POSITIVE_INFINITY,
userVerification: "preferred",
};
},
async verifyAuthentication(input) {
expectedOrigin = input.expectedOrigin;
expectedRpId = input.expectedRpId;
return { verified: true, credentialId: "cred-1", newCounter: 1 };
},
};
let time = 1_720_000_000_000;
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "passkey-test-secret-that-is-longer-than-thirty-two-characters",
passkeys: provider,
clock: { now: () => time },
});
const registered = await engine.register({
email: "passkey@example.com",
password: "StrongPassword123",
});
const start = await engine.beginPasskeyRegistration(registered.user!.id, {
rpId: "example.test",
rpName: "Test",
origin: "https://example.test",
});
expect(start.options.timeout).toBe(30_000);
expect(
await engine.finishPasskeyRegistration(registered.user!.id, {
key: start.key,
response: {},
rpId: "example.test",
origin: "https://example.test",
}),
).toBe(true);
const auth = await engine.beginPasskeyAuthentication({
identifier: "passkey@example.com",
rpId: "example.test",
origin: "https://example.test",
});
expect(auth.options.timeout).toBe(5 * 60_000);
time += 1;
expect(
(
await engine.finishPasskeyAuthentication({
key: auth.key,
response: { id: "cred-1" },
rpId: "example.test",
origin: "https://example.test",
})
).ok,
).toBe(true);
expect(expectedOrigin).toBe("https://example.test");
expect(expectedRpId).toBe("example.test");
const repeatedCounter = await engine.beginPasskeyAuthentication({
identifier: "passkey@example.com",
rpId: "example.test",
origin: "https://example.test",
});
expect(
await engine.finishPasskeyAuthentication({
key: repeatedCounter.key,
response: { id: "cred-1" },
}),
).toMatchObject({ ok: false, code: "passkey-counter-regression" });
await engine.setAccountStatus(registered.user!.id, "disabled");
const disabledAuth = await engine.beginPasskeyAuthentication({
identifier: "passkey@example.com",
rpId: "example.test",
origin: "https://example.test",
});
expect(
await engine.finishPasskeyAuthentication({
key: disabledAuth.key,
response: { id: "cred-1" },
}),
).toMatchObject({ ok: false, code: "account-disabled" });
});
test("rejects a passkey result that switches away from the identified user", async () => {
const store = new MemoryAuthStore();
const provider: PasskeyProvider = {
async registrationOptions() {
throw new Error("not used");
},
async verifyRegistration() {
return { verified: false };
},
async authenticationOptions() {
return {
challenge: "BAUG",
rpId: "example.test",
timeout: 60_000,
userVerification: "required",
};
},
async verifyAuthentication() {
return { verified: true, userId: "different-user" };
},
};
const engine = createAuthEngine({
store,
secret: "passkey-switch-secret-that-is-longer-than-thirty-two-characters",
passkeys: provider,
});
const registered = await engine.register({
email: "identified@example.com",
password: "StrongPassword123",
});
const auth = await engine.beginPasskeyAuthentication({
identifier: "identified@example.com",
rpId: "example.test",
origin: "https://example.test",
});
const result = await engine.finishPasskeyAuthentication({ key: auth.key, response: {} });
expect(result).toMatchObject({ ok: false, code: "passkey-user-mismatch" });
expect(registered.user?.id).toBeDefined();
});
test("passwordless flows respect account status and enrolled MFA", async () => {
const { engine, messages, now } = fixture();
const registered = await engine.register({
email: "passwordless-mfa@example.com",
password: "StrongPassword123",
});
const setup = await engine.beginTotp(registered.user!.id);
const token = await generateTotp(setup.secret, { timestamp: now() });
expect(await engine.confirmTotp(registered.user!.id, setup.credentialId, token)).toBe(true);
await engine.requestMagicLink("passwordless-mfa@example.com", "https://example.test");
const magicToken = messages.find((message) => message.template === "magic-link")!.token!;
expect(await engine.consumeMagicLink(magicToken)).toMatchObject({
ok: false,
code: "mfa-required",
requires: { mfa: expect.any(Array) },
});
const otp = await engine.requestOtpLogin("passwordless-mfa@example.com", "email-otp");
const otpCode = messages.filter((message) => message.template === "email-otp").at(-1)!.code!;
expect(await engine.completeOtpLogin(otp.id, otpCode)).toMatchObject({
ok: false,
code: "mfa-required",
});
await engine.setAccountStatus(registered.user!.id, "disabled");
const second = await engine.requestOtpLogin("passwordless-mfa@example.com", "email-otp");
expect(await engine.completeOtpLogin(second.id, "000000")).toMatchObject({
ok: false,
code: "invalid-otp",
});
});
test("temporary login locks do not activate a pending account when they expire", async () => {
let time = 1_720_000_000_000;
const store = new MemoryAuthStore();
const engine = createAuthEngine({
store,
secret: "pending-lock-secret-that-is-longer-than-thirty-two-characters",
requireVerifiedEmail: true,
maxFailedLogins: 1,
lockDurationMs: 60_000,
clock: { now: () => time },
});
const registered = await engine.register({
email: "pending-lock@example.com",
password: "StrongPassword123",
});
await engine.login({
identifier: "PENDING-LOCK@example.com",
password: "WrongPassword999",
captchaVerified: true,
});
expect((await store.findUserById(registered.user!.id))?.status).toBe("locked");
time += 61_000;
const login = await engine.login({
identifier: "pending-lock@example.com",
password: "StrongPassword123",
captchaVerified: true,
});
expect(login).toMatchObject({ ok: false, code: "email-unverified" });
expect((await store.findUserById(registered.user!.id))?.status).toBe("pending");
});
test("OAuth refreshes preserve stored tokens when a provider omits them", async () => {
const { engine } = fixture();
const registered = await engine.register({
email: "oauth-refresh@example.com",
password: "StrongPassword123",
});
const profile = { id: "oauth-refresh-id", email: "oauth-refresh@example.com", raw: {} };
const first = await engine.linkOAuth(registered.user!.id, "example", profile, {
access_token: "access-token",
refresh_token: "refresh-token",
token_type: "Bearer",
scope: "openid profile",
});
const second = await engine.linkOAuth(registered.user!.id, "example", profile);
expect(second.accessToken).toBe(first.accessToken);
expect(second.refreshToken).toBe(first.refreshToken);
expect(second.scope).toBe(first.scope);
});
test("disabled accounts cannot use previously issued recovery or verification tokens", async () => {
const { engine, messages } = fixture();
const registered = await engine.register({
email: "disabled-recovery@example.com",
password: "StrongPassword123",
});
const verificationToken = messages.find(
(message) => message.template === "verify-email",
)!.token!;
await engine.requestPasswordReset("disabled-recovery@example.com", "https://example.test");
const resetToken = messages.find((message) => message.template === "password-reset")!.token!;
await engine.setAccountStatus(registered.user!.id, "disabled");
expect(await engine.verifyEmail(verificationToken)).toMatchObject({
ok: false,
code: "account-disabled",
});
expect(await engine.resetPassword(resetToken, "ReplacementPassword456")).toMatchObject({
ok: false,
code: "account-disabled",
});
const before = messages.length;
await engine.requestPasswordReset("disabled-recovery@example.com", "https://example.test");
expect(messages).toHaveLength(before);
// Administrative re-enablement leaves the original one-time credentials
// available because the disabled-account checks did not consume them.
await engine.setAccountStatus(registered.user!.id, "active");
expect((await engine.verifyEmail(verificationToken)).ok).toBe(true);
expect((await engine.resetPassword(resetToken, "ReplacementPassword456")).ok).toBe(true);
});
test("a valid password reset clears a failed-login lock without activating pending users", async () => {
const store = new MemoryAuthStore();
const messages: AuthDeliveryMessage[] = [];
const engine = createAuthEngine({
store,
secret: "password-reset-lock-secret-that-is-longer-than-thirty-two-characters",
maxFailedLogins: 1,
delivery: {
async send(message) {
messages.push(message);
},
},
});
const registered = await engine.register({
email: "locked-reset@example.com",
password: "StrongPassword123",
});
await engine.login({
identifier: "locked-reset@example.com",
password: "WrongPassword123",
captchaVerified: true,
});
expect((await store.findUserById(registered.user!.id))?.status).toBe("locked");
await engine.requestPasswordReset("locked-reset@example.com", "https://example.test");
const token = messages
.filter((message) => message.template === "password-reset")
.at(-1)!.token!;
expect((await engine.resetPassword(token, "ReplacementPassword456")).ok).toBe(true);
expect((await store.findUserById(registered.user!.id))?.status).toBe("pending");
expect(
(
await engine.login({
identifier: "locked-reset@example.com",
password: "ReplacementPassword456",
captchaVerified: true,
})
).ok,
).toBe(true);
});
test("MFA email and SMS challenges require verified linked identities", async () => {
const { engine, messages, now } = fixture();
const registered = await engine.register({
email: "unverified-mfa@example.com",
password: "StrongPassword123",
});
const verificationToken = messages.find(
(message) => message.template === "verify-email",
)!.token!;
const setup = await engine.beginTotp(registered.user!.id);
const token = await generateTotp(setup.secret, { timestamp: now() });
expect(await engine.confirmTotp(registered.user!.id, setup.credentialId, token)).toBe(true);
const firstLogin = await engine.login({
identifier: "unverified-mfa@example.com",
password: "StrongPassword123",
});
expect(firstLogin).toMatchObject({
ok: false,
code: "mfa-required",
requires: { mfa: ["totp"] },
});
expect(await engine.beginMfaOtp(firstLogin.mfaToken!, "email-otp")).toBeUndefined();
expect((await engine.verifyEmail(verificationToken)).ok).toBe(true);
const secondLogin = await engine.login({
identifier: "unverified-mfa@example.com",
password: "StrongPassword123",
});
expect(secondLogin.requires?.mfa).toContain("email-otp");
expect(secondLogin.requires?.mfa).toContain("totp");
expect(await engine.beginMfaOtp(secondLogin.mfaToken!, "email-otp")).toBeDefined();
});
});
+336
View File
@@ -0,0 +1,336 @@
import { expect, test } from "bun:test";
import type { Context } from "@wrnexus/core";
import { createAuthEngine } from "../src/engine.ts";
import { createAuthHttpHandlers } from "../src/http/index.ts";
import { MemoryAuthStore } from "../src/stores/memory.ts";
function context(request: Request): Context {
const values = new Map<string, unknown>();
return {
req: request,
url: new URL(request.url),
params: {},
locals: {},
lang: "en",
t: (key: string) => key,
ip: "127.0.0.1",
user: null,
cookies: {} as Context["cookies"],
localStorage: {} as Context["localStorage"],
session: {
id: () => "http-test-session",
get: <T>(key: string) => values.get(key) as T | undefined,
getAll: () => Object.fromEntries(values),
set: (key: string, value: unknown) => {
values.set(key, value);
},
delete: (key: string) => {
values.delete(key);
},
regenerate: () => {},
clear: () => {
values.clear();
},
},
} as Context;
}
test("login handler never trusts a browser captchaVerified field", async () => {
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "http-handler-secret-that-is-longer-than-thirty-two-characters",
captchaThreshold: 0,
});
await engine.register({ email: "captcha@example.com", password: "StrongPassword123" });
const handlers = createAuthHttpHandlers({ engine });
const ctx = context(
new Request("https://example.test/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
identifier: "captcha@example.com",
password: "StrongPassword123",
captchaVerified: true,
}),
}),
);
const response = await handlers.login(ctx);
expect(response.status).toBe(401);
expect(await response.json()).toMatchObject({ code: "captcha-required" });
});
test("server-populated CAPTCHA verification permits the login attempt", async () => {
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "http-handler-secret-that-is-longer-than-thirty-two-characters",
captchaThreshold: 0,
});
await engine.register({ email: "verified@example.com", password: "StrongPassword123" });
const handlers = createAuthHttpHandlers({ engine });
const ctx = context(
new Request("https://example.test/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ identifier: "verified@example.com", password: "StrongPassword123" }),
}),
);
ctx.locals.captcha = { success: true };
const response = await handlers.login(ctx);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ ok: true });
});
test("login API returns a safe actionable invalid-credentials message", async () => {
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "http-login-message-secret-longer-than-thirty-two-characters",
});
const handlers = createAuthHttpHandlers({ engine });
const response = await handlers.login(
context(
new Request("https://example.test/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
identifier: "missing@example.com",
password: "WrongPassword999",
}),
}),
),
);
expect(response.status).toBe(401);
expect(await response.json()).toMatchObject({
ok: false,
code: "invalid-credentials",
message: "The email, phone, username, or password you entered is incorrect.",
});
});
test("HTTP handlers use navigation hooks configured on the auth engine", async () => {
const calls: string[] = [];
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "engine-navigation-hooks-secret-longer-than-thirty-two-characters",
onSignedIn(ctx, returnTo) {
calls.push(`in:${returnTo}`);
return Response.redirect(new URL(returnTo ?? "/account", ctx.url), 303);
},
onSignedOut(ctx) {
calls.push("out");
return Response.redirect(new URL("/sign-in", ctx.url), 303);
},
});
await engine.register({
email: "navigation@example.com",
password: "StrongPassword123",
});
const handlers = createAuthHttpHandlers({ engine });
const loginResponse = await handlers.login(
context(
new Request("https://example.test/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
identifier: "navigation@example.com",
password: "StrongPassword123",
returnTo: "/dashboard",
}),
}),
),
);
expect(loginResponse.status).toBe(303);
expect(loginResponse.headers.get("location")).toBe("https://example.test/dashboard");
const logoutResponse = await handlers.logout(
context(new Request("https://example.test/api/auth/logout", { method: "POST" })),
);
expect(logoutResponse.status).toBe(303);
expect(logoutResponse.headers.get("location")).toBe("https://example.test/sign-in");
expect(calls).toEqual(["in:/dashboard", "out"]);
});
test("register handler returns validation field errors before calling the engine", async () => {
let registerCalls = 0;
const engine = {
register: async () => {
registerCalls += 1;
return { ok: true };
},
} as unknown as ReturnType<typeof createAuthEngine>;
const handlers = createAuthHttpHandlers({ engine });
const ctx = context(
new Request("https://example.test/api/auth/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ displayName: "A", email: "bad", password: "short" }),
}),
);
const response = await handlers.register(ctx);
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({
ok: false,
errors: {
displayName: expect.any(String),
email: expect.any(String),
password: expect.any(String),
},
});
expect(registerCalls).toBe(0);
});
test("successful signup redirects to sign-in by default", async () => {
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "default-signup-redirect-secret-longer-than-thirty-two-characters",
});
const handlers = createAuthHttpHandlers({ engine });
const response = await handlers.register(
context(
new Request("https://example.test/api/auth/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
displayName: "Default Redirect",
email: "default-signup@example.com",
password: "StrongPassword123",
consent: true,
}),
}),
),
);
expect(response.status).toBe(303);
expect(response.headers.get("location")).toBe("https://example.test/sign-in");
});
test("onSuccessfulSignUp can safely auto-sign-in and redirect the new user", async () => {
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "automatic-signup-login-secret-longer-than-thirty-two-characters",
onSuccessfulSignUp() {
return { autoSignIn: true, redirectTo: "/welcome" };
},
});
const handlers = createAuthHttpHandlers({ engine });
const ctx = context(
new Request("https://example.test/api/auth/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
displayName: "Automatic Login",
email: "automatic-signup@example.com",
password: "StrongPassword123",
consent: true,
}),
}),
);
const response = await handlers.register(ctx);
expect(response.status).toBe(303);
expect(response.headers.get("location")).toBe("https://example.test/welcome");
expect(ctx.locals.authUser).toMatchObject({ displayName: "Automatic Login" });
expect(ctx.locals.authSession).toBeDefined();
});
test("signup auto-sign-in does not bypass verification policy", async () => {
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "verified-signup-policy-secret-longer-than-thirty-two-characters",
requireVerifiedEmail: true,
onSuccessfulSignUp() {
return { autoSignIn: true, redirectTo: "/account" };
},
});
const handlers = createAuthHttpHandlers({ engine });
const ctx = context(
new Request("https://example.test/api/auth/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
displayName: "Verification Required",
email: "verify-signup@example.com",
password: "StrongPassword123",
consent: true,
}),
}),
);
const response = await handlers.register(ctx);
expect(response.status).toBe(401);
expect(await response.json()).toMatchObject({
ok: false,
code: "email-unverified",
message: "Verify your email address before signing in.",
});
expect(ctx.locals.authSession).toBeUndefined();
});
test("authenticated OTP issue rejects an unlinked destination without throwing", async () => {
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "http-otp-secret-that-is-longer-than-thirty-two-characters",
});
const registered = await engine.register({
email: "owner@example.com",
password: "StrongPassword123",
});
const handlers = createAuthHttpHandlers({ engine });
const ctx = context(
new Request("https://example.test/api/auth/otp", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ method: "email-otp", destination: "other@example.com" }),
}),
);
ctx.user = registered.user!;
ctx.locals.authUser = registered.user!;
const response = await handlers.issueOtp(ctx);
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({ ok: false });
});
test("verification resend is generic and can resolve an unauthenticated identifier", async () => {
const messages: Array<{ template: string }> = [];
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "verification-resend-secret-that-is-longer-than-thirty-two-characters",
delivery: {
async send(message) {
messages.push(message);
},
},
});
await engine.register({
email: "resend@example.com",
password: "StrongPassword123",
});
const handlers = createAuthHttpHandlers({ engine });
const before = messages.length;
const known = await handlers.requestVerification(
context(
new Request("https://example.test/api/auth/verification/request", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "email", identifier: "resend@example.com" }),
}),
),
);
expect(known.status).toBe(200);
expect(await known.json()).toEqual({ ok: true });
expect(messages).toHaveLength(before + 1);
const unknown = await handlers.requestVerification(
context(
new Request("https://example.test/api/auth/verification/request", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "email", identifier: "missing@example.com" }),
}),
),
);
expect(unknown.status).toBe(200);
expect(await unknown.json()).toEqual({ ok: true });
expect(messages).toHaveLength(before + 1);
});
+32
View File
@@ -0,0 +1,32 @@
import { expect, test } from "bun:test";
import { publicKeyCreationOptions, publicKeyRequestOptions } from "../src/passkeys/index.ts";
test("passkey option conversion accepts canonical Base64URL values", () => {
const creation = publicKeyCreationOptions({
challenge: "AQID",
rp: { id: "example.test", name: "Example" },
user: { id: "BAUG", name: "user", displayName: "User" },
timeout: 60_000,
attestation: "none",
});
expect(Array.from(creation.challenge as Uint8Array)).toEqual([1, 2, 3]);
const request = publicKeyRequestOptions({
challenge: "BAUG",
rpId: "example.test",
timeout: 60_000,
userVerification: "preferred",
});
expect(Array.from(request.challenge as Uint8Array)).toEqual([4, 5, 6]);
});
test("passkey option conversion rejects malformed Base64URL values", () => {
expect(() =>
publicKeyRequestOptions({
challenge: "AQID=",
rpId: "example.test",
timeout: 60_000,
userVerification: "required",
}),
).toThrow("canonical Base64URL");
});
+211
View File
@@ -0,0 +1,211 @@
import { expect, test } from "bun:test";
import { createPluginRunner } from "@wrnexus/plugin";
import { authPlugin } from "../src/plugin.ts";
import { AUTH_ROUTE_DEFINITIONS } from "../src/routes/definitions.ts";
import { readFileSync } from "node:fs";
import { getDefaultAuthRouteOptions } from "../src/runtime.ts";
test("plugin contributes components, runtime, styles, migration, and toolbar", async () => {
const metadata = new Map<string, unknown>();
const runner = createPluginRunner(
authPlugin({ includeRoutes: true, includeMigrations: true, includeMiddleware: true }),
{
root: process.cwd(),
mode: "development",
command: "dev",
metadata,
warn() {},
},
);
const contributions = await runner.contributions();
expect(contributions.componentDirs).toHaveLength(1);
expect(contributions.clientRuntimes[0]).toMatchObject({ id: "auth", singleton: true });
expect(contributions.styles[0]?.id).toBe("auth-components");
expect(contributions.migrations.map((migration) => migration.id)).toEqual([
"wrnexus-auth-001",
"wrnexus-auth-002-otp-purpose",
]);
expect(contributions.routes.length).toBeGreaterThanOrEqual(30);
expect(contributions.middleware).toHaveLength(1);
});
test("unconfigured automatic discovery fails closed for routes, middleware, and migrations", async () => {
const metadata = new Map<string, unknown>();
const runner = createPluginRunner(authPlugin(), {
root: process.cwd(),
mode: "development",
command: "dev",
metadata,
warn() {},
});
const contributions = await runner.contributions();
expect(contributions.routes).toHaveLength(0);
expect(contributions.middleware).toHaveLength(0);
expect(contributions.migrations).toHaveLength(0);
expect(contributions.componentDirs).toHaveLength(1);
expect(contributions.clientRuntimes).toHaveLength(1);
});
test("config.auth controls route groups and migrations without explicit plugin options", async () => {
const metadata = new Map<string, unknown>();
const runner = createPluginRunner(authPlugin(), {
root: process.cwd(),
mode: "development",
command: "dev",
metadata,
warn() {},
});
await runner.configure({
auth: {
engine: {} as never,
migrations: false,
routes: { enabled: true, registration: false, passkeys: false },
},
});
const contributions = await runner.contributions();
expect(contributions.migrations).toHaveLength(0);
expect(contributions.routes.some((route) => route.path === "/api/auth/register")).toBe(false);
expect(contributions.routes.some((route) => route.path.includes("/passkeys/"))).toBe(false);
expect(contributions.routes.some((route) => route.path === "/api/auth/login")).toBe(true);
});
test("config.auth resolves navigation hooks from the configured engine", async () => {
const onSignedIn = () => new Response(null, { status: 204 });
const onSignedOut = () => new Response(null, { status: 204 });
const runner = createPluginRunner(authPlugin(), {
root: process.cwd(),
mode: "development",
command: "dev",
metadata: new Map(),
warn() {},
});
await runner.configure({
auth: {
engine: { onSignedIn, onSignedOut } as never,
routes: true,
},
});
expect(getDefaultAuthRouteOptions().onSignedIn).toBe(onSignedIn);
expect(getDefaultAuthRouteOptions().onSignedOut).toBe(onSignedOut);
});
test("auth runtime contains built-in browser schemas", async () => {
const metadata = new Map<string, unknown>();
const runner = createPluginRunner(authPlugin({ includeMigrations: false }), {
root: process.cwd(),
mode: "development",
command: "dev",
metadata,
warn() {},
});
const contributions = await runner.contributions();
expect(contributions.clientRuntimes[0]?.source).toContain("auth-password-request");
expect(contributions.clientRuntimes[0]?.source).toContain("auth-register");
});
test("each package auth route uses a route-specific entry module", async () => {
const metadata = new Map<string, unknown>();
const runner = createPluginRunner(
authPlugin({
includeRoutes: true,
includeMigrations: false,
includeMiddleware: false,
}),
{
root: process.cwd(),
mode: "development",
command: "dev",
metadata,
warn() {},
},
);
const contributions = await runner.contributions();
const entries = contributions.routes.map((route) => route.entry);
const passwordRequest = contributions.routes.find(
(route) => route.path === "/api/auth/password/request",
);
expect(new Set(entries).size).toBe(entries.length);
expect(
passwordRequest?.entry.replace(/\\/g, "/").endsWith("/src/routes/api/password-request.ts"),
).toBe(true);
for (const definition of AUTH_ROUTE_DEFINITIONS) {
const route = contributions.routes.find((item) => item.path === definition.path);
expect(route).toBeDefined();
const source = readFileSync(route!.entry, "utf8");
expect(source).toContain(`invokeAuthHandler("${definition.handler}"`);
expect(source.includes("dispatchAuthRoute")).toBe(false);
for (const method of definition.methods) {
expect(source).toContain(`export function ${method}`);
}
}
});
test("config.auth registers package routes", async () => {
const metadata = new Map<string, unknown>();
const runner = createPluginRunner(authPlugin(), {
root: process.cwd(),
mode: "development",
command: "dev",
metadata,
warn() {},
});
await runner.configure({
auth: {
engine: {} as never,
routes: true,
middleware: true,
migrations: false,
},
});
const contributions = await runner.contributions();
expect(contributions.routes.some((route) => route.path === "/api/auth/password/request")).toBe(
true,
);
expect(contributions.routes.some((route) => route.path === "/api/auth/register")).toBe(true);
expect(contributions.routes.some((route) => route.path === "/api/auth/login")).toBe(true);
expect(contributions.middleware).toHaveLength(1);
expect(contributions.migrations).toHaveLength(0);
});
test("config.auth can disable route groups", async () => {
const metadata = new Map<string, unknown>();
const runner = createPluginRunner(authPlugin(), {
root: process.cwd(),
mode: "development",
command: "dev",
metadata,
warn() {},
});
await runner.configure({
auth: {
engine: {} as never,
migrations: false,
routes: {
enabled: true,
password: true,
passkeys: false,
},
},
});
const contributions = await runner.contributions();
expect(contributions.routes.some((route) => route.path === "/api/auth/password/request")).toBe(
true,
);
expect(contributions.routes.some((route) => route.path.includes("/passkeys/"))).toBe(false);
});
+21
View File
@@ -0,0 +1,21 @@
import { expect, test } from "bun:test";
import { createKeyring, generateKey, seal } from "@wrnexus/encryption";
import { createAuthSecretProtector } from "../src/protector.ts";
test("secret protector binds encrypted values to their auth purpose", async () => {
const keyring = createKeyring([{ id: "primary", secret: await generateKey(), active: true }]);
const protector = createAuthSecretProtector(keyring);
const protectedValue = await protector.protect("secret-value", "totp");
expect(await protector.reveal(protectedValue, "totp")).toBe("secret-value");
await expect(protector.reveal(protectedValue, "oauth-access")).rejects.toThrow(
"WRN-AUTH-SECRET-PURPOSE",
);
});
test("secret protector can read legacy unbound ciphertext", async () => {
const keyring = createKeyring([{ id: "primary", secret: await generateKey(), active: true }]);
const protector = createAuthSecretProtector(keyring);
const legacy = await seal("legacy-secret", keyring);
expect(await protector.reveal(legacy, "oauth-refresh")).toBe("legacy-secret");
});
+35
View File
@@ -0,0 +1,35 @@
import { expect, test } from "bun:test";
import { evaluateAuthRisk } from "../src/risk.ts";
test("risk scoring escalates CAPTCHA, MFA, and blocking", () => {
expect(evaluateAuthRisk({ failedAttempts: 2 })).toMatchObject({
level: "low",
requireCaptcha: false,
});
expect(evaluateAuthRisk({ failedAttempts: 3, unfamiliarDevice: true })).toMatchObject({
requireCaptcha: true,
});
expect(
evaluateAuthRisk({ unusualIp: true, impossibleTravel: true, unfamiliarDevice: true }),
).toMatchObject({ requireMfa: true });
expect(evaluateAuthRisk({ accountLocked: true })).toMatchObject({
block: true,
level: "critical",
});
});
test("risk scoring remains finite for malformed numeric signals and policy", () => {
const result = evaluateAuthRisk(
{ customScore: Number.NaN, failedAttempts: Number.POSITIVE_INFINITY },
{
captchaThreshold: Number.NaN,
mfaThreshold: Number.POSITIVE_INFINITY,
blockThreshold: -10,
},
);
expect(result.score).toBe(0);
expect(result.level).toBe("low");
expect(result.requireCaptcha).toBe(false);
expect(result.requireMfa).toBe(false);
expect(result.block).toBe(true);
});
+119
View File
@@ -0,0 +1,119 @@
import { expect, test } from "bun:test";
import type { Context } from "@wrnexus/core";
import { createAuthEngine } from "../src/engine.ts";
import { setDefaultAuthEngine } from "../src/runtime.ts";
import { MemoryAuthStore } from "../src/stores/memory.ts";
import { POST as requestPasswordReset } from "../src/routes/api/password-request.ts";
import { GET as listSessions } from "../src/routes/api/sessions.ts";
import { POST as passkeyLoginOptions } from "../src/routes/api/passkeys-login-options.ts";
function context(request: Request): Context {
const values = new Map<string, unknown>();
return {
req: request,
// Deliberately use unrelated values. A route-specific package entry must
// not infer its endpoint from ctx.url, ctx.req.url, params, or locals.
url: new URL("https://example.test/__wrnexus/rewritten"),
params: {},
locals: {},
lang: "en",
t: (key: string) => key,
ip: "127.0.0.1",
user: null,
cookies: {
get: (name: string) => (name === "wire-csrf" ? "route-csrf-token" : undefined),
} as Context["cookies"],
localStorage: {} as Context["localStorage"],
session: {
id: () => "route-test-session",
get: <T>(key: string) => values.get(key) as T | undefined,
getAll: () => Object.fromEntries(values),
set: (key: string, value: unknown) => {
values.set(key, value);
},
delete: (key: string) => {
values.delete(key);
},
regenerate: () => {},
clear: () => {
values.clear();
},
},
} as Context;
}
test("route-specific password recovery entry cannot fall through to Not Found", async () => {
setDefaultAuthEngine(
createAuthEngine({
store: new MemoryAuthStore(),
secret: "route-specific-entry-secret-longer-than-thirty-two-characters",
}),
);
const request = new Request("https://example.test/completely/unrelated", {
method: "POST",
headers: {
"content-type": "application/json",
"x-csrf-token": "route-csrf-token",
},
body: JSON.stringify({ identifier: "missing@example.test" }),
});
const response = await requestPasswordReset(context(request));
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
});
test("package auth routes reject unsafe requests without CSRF verification", async () => {
const request = new Request("https://example.test/api/auth/password/request", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ identifier: "missing@example.test" }),
});
const response = await requestPasswordReset(context(request));
expect(response.status).toBe(403);
expect(await response.json()).toMatchObject({ ok: false, error: "Invalid CSRF token" });
});
test("safe package auth routes do not require a CSRF token", async () => {
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "safe-route-secret-that-is-longer-than-thirty-two-characters",
});
const registered = await engine.register({
email: "sessions@example.test",
password: "StrongPassword123",
});
setDefaultAuthEngine(engine);
const ctx = context(new Request("https://example.test/api/auth/sessions", { method: "GET" }));
ctx.user = registered.user!;
ctx.locals.authUser = registered.user!;
const response = await listSessions(ctx);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ ok: true, sessions: [] });
});
test("passkey routes return a controlled response when no provider is configured", async () => {
setDefaultAuthEngine(
createAuthEngine({
store: new MemoryAuthStore(),
secret: "missing-passkey-provider-secret-longer-than-thirty-two-characters",
}),
);
const request = new Request("https://example.test/api/auth/passkeys/login/options", {
method: "POST",
headers: {
"content-type": "application/json",
"x-csrf-token": "route-csrf-token",
},
body: JSON.stringify({}),
});
const response = await passkeyLoginOptions(context(request));
expect(response.status).toBe(503);
expect(await response.json()).toMatchObject({
ok: false,
code: "passkey-provider-not-configured",
});
});
+108
View File
@@ -0,0 +1,108 @@
import { expect, test } from "bun:test";
import { MemoryAuthStore } from "../src/stores/memory.ts";
import type { AuthIdentity, OAuthAccount, PasskeyCredential } from "../src/types.ts";
function identity(overrides: Partial<AuthIdentity> = {}): AuthIdentity {
return {
id: "identity-1",
userId: "user-1",
type: "email",
value: "first@example.com",
normalizedValue: "first@example.com",
primary: true,
createdAt: 1,
updatedAt: 1,
...overrides,
};
}
function passkey(overrides: Partial<PasskeyCredential> = {}): PasskeyCredential {
return {
id: "passkey-1",
userId: "user-1",
credentialId: "credential-1",
publicKey: "public-key",
counter: 0,
transports: ["internal"],
name: "Passkey",
createdAt: 1,
...overrides,
};
}
function oauth(overrides: Partial<OAuthAccount> = {}): OAuthAccount {
return {
id: "oauth-1",
userId: "user-1",
provider: "example",
providerAccountId: "provider-account-1",
createdAt: 1,
updatedAt: 1,
...overrides,
};
}
test("memory identity updates re-key lookups and reject collisions", async () => {
const store = new MemoryAuthStore();
const first = identity();
const second = identity({
id: "identity-2",
userId: "user-2",
value: "second@example.com",
normalizedValue: "second@example.com",
});
await store.createIdentity(first);
await store.createIdentity(second);
first.value = "renamed@example.com";
first.normalizedValue = "renamed@example.com";
first.updatedAt = 2;
await store.updateIdentity(first);
expect(await store.findIdentity("email", "first@example.com")).toBeUndefined();
expect(await store.findIdentity("email", "renamed@example.com")).toMatchObject({
id: "identity-1",
});
first.value = second.value;
first.normalizedValue = second.normalizedValue;
await expect(store.updateIdentity(first)).rejects.toThrow("WRN-AUTH-IDENTITY-EXISTS");
});
test("memory store enforces passkey credential uniqueness", async () => {
const store = new MemoryAuthStore();
await store.createPasskey(passkey());
await expect(
store.createPasskey(
passkey({ id: "passkey-2", userId: "user-2", credentialId: "credential-1" }),
),
).rejects.toThrow("WRN-AUTH-PASSKEY-EXISTS");
await store.createPasskey(
passkey({ id: "passkey-2", userId: "user-2", credentialId: "credential-2" }),
);
await expect(
store.updatePasskey(
passkey({ id: "passkey-2", userId: "user-2", credentialId: "credential-1" }),
),
).rejects.toThrow("WRN-AUTH-PASSKEY-IMMUTABLE");
});
test("memory store enforces OAuth provider-account uniqueness", async () => {
const store = new MemoryAuthStore();
await store.createOAuthAccount(oauth());
await expect(
store.createOAuthAccount(
oauth({ id: "oauth-2", userId: "user-2", providerAccountId: "provider-account-1" }),
),
).rejects.toThrow("WRN-AUTH-OAUTH-ACCOUNT-EXISTS");
await store.createOAuthAccount(
oauth({ id: "oauth-2", userId: "user-2", providerAccountId: "provider-account-2" }),
);
await expect(
store.updateOAuthAccount(
oauth({ id: "oauth-2", userId: "user-2", providerAccountId: "provider-account-1" }),
),
).rejects.toThrow("WRN-AUTH-OAUTH-ACCOUNT-IMMUTABLE");
});
+41
View File
@@ -0,0 +1,41 @@
import { expect, test } from "bun:test";
import {
decodeBase32,
generateTotp,
generateTotpSecret,
totpUri,
verifyTotp,
} from "../src/totp/index.ts";
test("TOTP matches the RFC 6238 SHA-1 vector", async () => {
const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
expect(await generateTotp(secret, { timestamp: 59_000, digits: 8 })).toBe("94287082");
expect(await verifyTotp(secret, "94287082", { timestamp: 59_000, digits: 8, window: 0 })).toEqual(
{ valid: true, counter: 1 },
);
});
test("TOTP rejects malformed secrets, tokens, and unsafe options", async () => {
expect(() => decodeBase32("JBSW0Y3P")).toThrow("Invalid base32 secret");
expect(() => decodeBase32("====")).toThrow("Invalid base32 secret");
expect(() => generateTotpSecret(() => new Uint8Array(19))).toThrow(
"must return exactly 20 bytes",
);
await expect(generateTotp("JBSWY3DPEHPK3PXP", { period: 0 })).rejects.toThrow("TOTP period");
expect(await verifyTotp("JBSWY3DPEHPK3PXP", "12ab56", { timestamp: 59_000 })).toEqual({
valid: false,
});
});
test("TOTP URI validates and normalizes configuration", () => {
const uri = totpUri({
issuer: " WorkRoot ",
accountName: " user@example.com ",
secret: "JBSW Y3DP-EHPK3PXP",
});
expect(uri).toContain("secret=JBSWY3DPEHPK3PXP");
expect(uri).toContain("issuer=WorkRoot");
expect(() =>
totpUri({ issuer: "", accountName: "user@example.com", secret: "JBSWY3DPEHPK3PXP" }),
).toThrow("issuer and account name");
});
+58
View File
@@ -0,0 +1,58 @@
import { expect, test } from "bun:test";
import {
invitationAcceptSchema,
loginSchema,
mfaSchema,
otpLoginRequestSchema,
registerSchema,
signUpSchema,
} from "../src/validation.ts";
test("authentication schemas reject malformed input", () => {
expect(registerSchema.parse({ email: "bad", password: "short", displayName: "A" }).ok).toBe(
false,
);
expect(loginSchema.parse({ identifier: "", password: "" }).ok).toBe(false);
expect(
otpLoginRequestSchema.parse({ identifier: "person@example.com", method: "voice" }).ok,
).toBe(false);
expect(invitationAcceptSchema.parse({ token: "short" }).ok).toBe(false);
expect(mfaSchema.parse({ mfaToken: "short", method: "unknown", code: "1" }).ok).toBe(false);
});
test("sign-up schema is shared by browser and server registration", () => {
const invalid = signUpSchema.parse({
displayName: "A",
email: "bad",
password: "weak",
consent: false,
});
expect(invalid.ok).toBe(false);
expect(invalid.errors).toMatchObject({
displayName: "Enter your full name",
email: "Enter a valid email address",
consent: "Accept the terms and privacy policy to continue",
});
expect(
signUpSchema.parse({
displayName: "Ada Lovelace",
email: "ada@example.com",
password: "StrongPassword123",
consent: true,
}).ok,
).toBe(true);
});
test("built-in browser schema registry covers packaged auth forms", async () => {
const { authBrowserSchemaDescriptors, authSchemas } = await import("../src/validation.ts");
const descriptors = authBrowserSchemaDescriptors(authSchemas);
expect(descriptors["auth-register"]).toBeDefined();
expect(descriptors["auth-password-request"]).toBeDefined();
expect(descriptors["auth-session-revoke"]).toBeDefined();
expect(descriptors["auth-authenticator-setup"]).toBeDefined();
expect(descriptors["auth-authenticator-confirm"]).toBeDefined();
expect(descriptors["auth-authenticator-disable"]).toBeDefined();
expect(descriptors["auth-recovery-codes"]).toBeDefined();
expect(descriptors["auth-empty"]).toBeDefined();
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}