371 lines
12 KiB
Markdown
371 lines
12 KiB
Markdown
# @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.
|