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