@urbicon-ui/auth
v8.26.1
Published
Authentication for SvelteKit — JWT sessions, passkeys/WebAuthn, notifications and email with zero runtime dependencies
Maintainers
Readme
@urbicon-ui/auth
Zero-runtime-dependency authentication, user-management, and notification system for SvelteKit. Part of the vertical Urbicon UI platform.
All crypto is implemented with the Web Crypto API — no bcrypt, no jsonwebtoken, no Web-Push vendor SDK. Server-side handler factories, a Handle-Hook for SvelteKit, an adapter interface (Prisma adapter included), and 14 blocks-based UI components covering login, registration, password reset, email verification, invitation management, passkeys, account management, active sessions, two-factor (TOTP), and notifications.
Maturity: core stable (hardened for production SvelteKit deployments, including persistent-store adapters for challenges / rate-limits / refresh tokens); the newest self-service surfaces — account management, session listing, TOTP 2FA, invitation management, and passkey management — are
beta. See AUTH.md — Known Limitations for the residual gap list — the same reference also ships inside this package as./docs/AUTH.md.
New here? Jump to the Quickstart — a copy-paste setup that runs in five minutes with no database or mail server. Then graduate to Production and Advanced.
Installation
bun add @urbicon-ui/authPeer dependencies: svelte (^5.57.0), @sveltejs/kit, @urbicon-ui/blocks, @urbicon-ui/i18n.
Runtime dependencies: none.
The declared @sveltejs/kit range is 2.x. The package runs under SvelteKit 3 next as well;
the incorrect-peer warning bun add prints there is expected and stays until Kit 3 has a
release candidate, when the range widens.
Pin with ~, not ^: until the launch of ui.urbicon.de is announced, an 8.x minor may carry
breaking changes (VERSIONING.md § The pre-launch window);
each one is listed under Breaking Changes in the changelog — read it before a minor upgrade.
Stylesheet. The components emit Tailwind classes, and a Tailwind build never scans
node_modules on its own — each package ships a stylesheet whose @source directive points
Tailwind at its components. Import this package's stylesheet next to the blocks one, in the
file that holds your Tailwind import:
/* app.css */
@import 'tailwindcss';
@import '@urbicon-ui/blocks/style/index.css'; /* tokens + the blocks @source */
@import '@urbicon-ui/auth/style/index.css'; /* the auth @source — no tokens of its own */Without the auth line the components still render, but every class that lives only in
this package (the sm: layouts of the pages and managers, the link colour of the auth
pages) is missing from the compiled CSS. A project that mounted the components before this
stylesheet existed adds the one line and is done.
Runtime target: Node.js ≥ 20 or Bun. All crypto is Web Crypto (globalThis.crypto, global since Node 20), but password hashing and the TOTP secret cipher use Node's Buffer — which puts the login/register path on a Node/Bun runtime. Edge/Workers/Deno-deploy work only behind a Buffer polyfill (e.g. Cloudflare nodejs_compat); the Web Crypto paths themselves are edge-clean.
Feature Matrix
| Area | Capability | Standards |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
| Sessions | JWT (HMAC-SHA256), httpOnly/secure/sameSite=lax cookie, 7-day TTL (shortens to 15 min when refresh-rotation is on), tokenVersion invalidation, opt-in key rotation via kid + previousSecrets | — |
| Refresh tokens | Opt-in rotation via config.refreshToken + repos.refreshToken; 15-min access / 30-day rotating refresh, token families, SHA-256-hashed storage, reuse-detection (replaying a rotated token revokes the whole family), transparent rotation in createAuthHandle and explicit createRefreshHandler | — |
| Passwords | PBKDF2 (600k iter, SHA-256), legacy bcrypt auto-upgraded via dual-verify | — |
| Passkeys | Registration + authentication, counter check for cloning, ES256 + RS256, pluggable challenge store (in-memory default, optional Redis/Prisma/etc. via ChallengeStore), User-Verification (UV) enforced by default — requireUserVerification: false opts out | WebAuthn Level 2, FIDO2 |
| Two-factor (2FA) | Opt-in TOTP second factor via config.twoFactor + repos.backupCode: zero-dep RFC-6238/4226 codes, AES-256-GCM-encrypted secret at rest, signed short-lived pending-2FA cookie between password and code, single-use SHA-256 backup codes, strict per-step rate-limit. Login two-step + TwoFactorManager UI. Passkey logins are not gated — a claim that rests on passkey UV enforcement being on by default. | RFC 6238, 4226, 4648 |
| Web Push | ECDH P-256 + HKDF + AES-128-GCM, VAPID JWT signing, opt-in per-endpoint rate-limit | RFC 8291, 8292, 8188 |
| Email | Transport interface, Lettermint adapter + console logger (dev) | — |
| CSRF | Origin-header validation (always on for requests routed through createAuthHandle) + opt-in Double-Submit-Cookie, optional __Host- cookie prefix (csrf.useHostPrefix) against subdomain injection | — |
| Rate-limit | Pluggable store (in-memory default, optional Redis/Prisma/etc. adapter via RateLimitStore), configurable window/max | — |
| Security headers | Always on: nosniff, X-Frame-Options: DENY, Referrer-Policy, Permissions-Policy. Configurable via config.securityHeaders: HSTS (default max-age=63072000; includeSubDomains, only in a secure deployment — no cookieSecure: false on any cookie config) + CSP hook (default frame-ancestors 'none') | — |
Package Exports
| Export | Condition | Contents |
| --------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| @urbicon-ui/auth | Universal | Client stores, components, types |
| @urbicon-ui/auth/server | Server | Handlers, auth core, adapters |
| @urbicon-ui/auth/server/adapters/prisma | Server | Prisma adapter factory (createPrismaRepos) |
| @urbicon-ui/auth/server/adapters/in-memory | Server | In-memory adapter (createInMemoryRepos, per-repository factories on a createInMemoryStore()) — dev/test |
| @urbicon-ui/auth/server/adapters/conformance | Server (tests) | Adapter conformance suite (describeRepositoryConformance), wired to vitest |
| @urbicon-ui/auth/server/adapters/conformance-core | Server (tests) | The same suite without a runner import — pass { runner: { describe, it, expect } } (bun:test as-is; jest needs expect: (a) => expect(a)) |
| @urbicon-ui/auth/server/email/lettermint | Server | Lettermint email transport |
| @urbicon-ui/auth/server/email/console | Server | Console email transport (dev only) |
| @urbicon-ui/auth/sw | Service worker | Push + notification-click handlers |
| @urbicon-ui/auth/i18n/en | Universal | English locale bundle — the built-in one; import it for a parity test or as the base of your own |
| @urbicon-ui/auth/i18n/de | Universal | German locale bundle — reaches the components once registerAuthLocale('de', de) has run |
UI Components
All use @urbicon-ui/blocks primitives and honour unstyled + slotClasses + snippet overrides.
t takes a PartialAuthLocale, merged by mergeAuthLocale over the bundle registered for the
active locale — English unless registerAuthLocale('de', de) has run for it. See
AUTH.md → UI Components and
AUTH.md → Locales.
| Component | Purpose |
| ---------------------- | -------------------------------------------- |
| LoginPage | Login form with optional passkey entry point |
| RegisterPage | Registration form (invitation-gated) |
| ForgotPasswordPage | Password-reset request |
| ResetPasswordPage | Password-reset with confirmation |
| VerifyEmailPage | Auto-verifying email confirmation |
| InvitationManager | Admin invitation list + create/revoke |
| PasskeyManager | WebAuthn credential management |
| AccountSettings | Change name/email/password + delete account |
| SessionManager | List active sessions + sign out devices |
| TwoFactorManager | Enrol/disable TOTP 2FA + show backup codes |
| NotificationCenter | Notification list with read/delete |
| NotificationBadge | Unread-count badge |
| NotificationListener | Headless SSE listener |
| PushPermissionPrompt | Push-notification opt-in |
Getting Started
Three stages, each building on the last: a five-minute dev quickstart, a production
hardening pass, then the advanced surface. createAuthHandle is mandatory in every
stage — it hydrates the session, guards routes, applies the response security headers,
and enforces CSRF. Skip it and those protections are simply off.
Stage 1 — Quickstart (dev, 5 minutes)
Runs with no database and no mail server: the in-memory adapter keeps everything in heap Maps, the console transport prints emails to your terminal. State is wiped on every restart — dev only, never production.
1. Dependencies — src/lib/server/auth-setup.ts:
import { createAuthDeps } from '@urbicon-ui/auth/server';
import { createInMemoryRepos } from '@urbicon-ui/auth/server/adapters/in-memory';
import { createConsoleEmailTransport } from '@urbicon-ui/auth/server/email/console';
export const authDeps = createAuthDeps({
config: {
jwt: { secret: 'dev-secret-change-me', cookieSecure: false }, // cookieSecure:false = http dev
appUrl: 'http://localhost:5173', // trusted base for email links — required
routes: { afterLogin: '/', loginPage: '/auth/login' }
},
repos: createInMemoryRepos(),
email: createConsoleEmailTransport() // dev only — prints emails to the terminal
});email is optional. This quickstart mounts register and forgot-password, which mail a
link, so it needs a transport — those two and createChangeEmailHandler throw at wiring
time without one. createInvitationHandlers mounts without a transport and declines to
mail only the invites that ask for it (sendEmail: true, answered 201 with emailSent:
false and the inviteUrl); the copy-link flow needs none. An app that mounts only login/logout/me and the session
routes passes no email at all.
createInMemoryRepos() is a fresh createInMemoryStore() with every repository built on it.
Need only a piece — the refresh-token repository beside a user store of your own? Build that
factory on a store handle: createInMemoryRefreshTokenRepository(createInMemoryStore()).
Repositories on one store share its rows, and user.delete erases across all of them. The
store carries the role type — createInMemoryStore<'ADMIN' | 'USER'>() — and every factory
infers it from the handle; a role-typed factory on an untyped store is a type error.
createAuthDeps fills in secure brute-force defaults automatically (login rate-limit
5 / 15 min + lockout 5 / 15 min) — even the quickstart isn't an open door. Every
rateLimit key gets a default, and configuring some keys is a merge rather than a
replacement (so rateLimit: { register } never silently leaves login unprotected); the
per-key numbers and their reasoning are in
docs/AUTH.md. The lockout default applies only
when you set neither rateLimit nor lockout. Opt out of either explicitly with null.
A failed attempt stops counting once it is lockout.decayMinutes old (default 60), so
typos on separate days never add up to a lockout.
A cookieSecure: false on the session, CSRF or refresh cookie marks this as a
non-HTTPS dev deployment, which suppresses the production hardening warnings (and HSTS)
you'd otherwise see, and drops the __Host- prefix from the 2FA and passkey cookies so
the browser keeps them. Set it on every cookie config you declare, or on none — a mix is
warned about at wiring time (docs/AUTH.md → Secure deployment).
2. Hook — src/hooks.server.ts:
import { createAuthHandle } from '@urbicon-ui/auth/server';
import { authDeps } from '$lib/server/auth-setup';
export const handle = createAuthHandle({ config: authDeps.config, repos: authDeps.repos });Machine callers — a cron runner posting with a secret header, an OAuth token endpoint, an API-key route — send no
Origin, so the handle's CSRF gate answers them403. Declare them incsrf: { exempt: ['/api/cron/'] }: the hook then resolves no session for them (locals.userisnull) and they must authenticate every request without the session cookie — a route that reads it itself keeps working with the gate off, so never exempt a cookie-authorised route (/api/auth/is refused). What SvelteKit's own kernel CSRF gate still does to form-encoded ones, and its build-time off-switch: AUTH.md → Machine callers.
3. API route stubs — one file per handler, e.g. src/routes/api/auth/login/+server.ts:
import { createLoginHandler } from '@urbicon-ui/auth/server';
import { authDeps } from '$lib/server/auth-setup';
export const { POST } = createLoginHandler(authDeps);Repeat for logout, register, forgot-password, reset-password, verify-email, me, and
password-policy (createPasswordPolicyHandler — it publishes config.password, so the sign-up
and reset forms gate on the same rules the server checks; without it they fall back to the package
defaults, min 8 and no character classes). One option worth knowing here:
createLogoutHandler(authDeps, { invalidateAccessTokens: true }) ends every session of the
account instead of this browser's — it bumps the user's tokenVersion and revokes every refresh
family — so an access token copied before the logout stops verifying, at the price of signing the
user's other devices out (AUTH.md → Logout).
4. UI page — src/routes/auth/login/+page.svelte:
<script>
import { LoginPage } from '@urbicon-ui/auth';
import { en } from '@urbicon-ui/auth/i18n/en';
import { goto } from '$app/navigation';
</script>
<LoginPage t={en} onSuccess={() => goto('/')} />You now have a working email/password flow. Registration is invitation-gated, so seed one
invitation first —
await authDeps.repos.invitation.create({ email: '[email protected]', role: 'USER', invitedById: 'seed' }) —
then register, watch the verification email print to your terminal, and log in.
Stage 2 — Production
Swap the two dev pieces — in-memory → Prisma, console → a real transport — and turn on the hardening layers. Everything here is opt-in and additive: the Stage 1 hook and route stubs are unchanged; you're only growing the config.
// src/lib/server/auth-setup.ts
import { createAuthDeps } from '@urbicon-ui/auth/server';
import { createPrismaRepos } from '@urbicon-ui/auth/server/adapters/prisma';
import { createLettermintTransport } from '@urbicon-ui/auth/server/email/lettermint';
import { APP_URL, JWT_SECRET, LETTERMINT_TOKEN } from '$env/static/private';
import { prisma } from './prisma';
import { appLogger } from './logging'; // your own AuthLogger { warn, error }
type AppRole = 'ADMIN' | 'USER';
export const authDeps = createAuthDeps<AppRole>({
config: {
jwt: { secret: JWT_SECRET }, // cookieSecure defaults true → HTTPS + auto HSTS
appUrl: APP_URL, // trusted base for email links — never request.url; a private var, so no PUBLIC_ prefix
email: { from: 'Acme <[email protected]>' }, // default sender for all auth emails
csrf: { doubleSubmit: true }, // token layer on top of the always-on Origin check — only with header-capable clients (see checklist)
refreshToken: { accessTokenTtl: '15m', refreshTokenTtl: '30d' }, // rotating refresh
rateLimit: {
login: { windowMs: 900_000, max: 5 },
forgotPassword: { windowMs: 3_600_000, max: 3 }, // reset *request* (email send)
resetPassword: { windowMs: 3_600_000, max: 5 } // reset *consume* (token redemption)
},
lockout: { maxAttempts: 5, durationMinutes: 15, decayMinutes: 60 }, // decay: how long a failure counts
tokenTtl: { emailVerification: '24h', passwordReset: '1h', emailChange: '1h' }, // mailed link windows
routes: { afterLogin: '/', loginPage: '/auth/login' },
logger: appLogger
},
// Same sink for both: wiring diagnostics from the adapter (a missing Prisma
// model drops its feature) land with the rest of the auth logs.
repos: createPrismaRepos<AppRole>(prisma, { logger: appLogger }),
email: createLettermintTransport({ token: LETTERMINT_TOKEN }) // sends via the Lettermint v2 API
});Add a refresh route stub (createRefreshHandler) once rotation is on. With the
RefreshToken model in your Prisma schema (see prisma/auth-schema.prisma), the handle
hook rotates the refresh cookie whenever the access token expires and revokes the old one;
replaying a revoked token triggers family-wide revocation — a stolen-token scenario
logs every session in that family out. Two requests rotating the same token at once (a
browser's parallel tabs) are tolerated for ten seconds — but only while the family is still
live: after a family-wide revocation or a "sign out everywhere", the spent token is refused
inside that window as well.
Production-readiness checklist
Mirrors AUTH.md → Production-Readiness Checklist:
- [ ] HTTPS enforced — cookies default to
secure: true; HSTS is emitted automatically as long as no cookie config sayscookieSecure: false(the secure-deployment signal). - [ ] CSRF Double-Submit decided (
csrf.doubleSubmit: true) — only when every cookie-auth mutation sends thex-csrf-tokenheader (package stores/components orcsrfFetch). SvelteKit Remote Functions and no-JS form posts can't send it — with those in play keep itfalse; the always-on Origin check is the complete layer there. OptionallyuseHostPrefix: true(HTTPS-only) — then setuseHostPrefix: trueon the client stores/components too. - [ ] Refresh-token rotation on (
refreshToken: {}+repos.refreshToken) — non-breaking, recommended. - [ ] Rate-limit + lockout active (defaulted by
createAuthDeps; tune per handler). Use a persistentRateLimitStorewhen running >1 instance. - [ ] Persistent stores for challenges / refresh tokens / rate limits at >1 instance.
- [ ] CSP tuned to your app (
securityHeaders.csp) — the default only blocks framing. - [ ]
appUrlset to the real public origin;JWT_SECRETfrom a secret store, with akeyId+previousSecretsrotation runbook ready. - [ ] Monitoring on auth-handler latency + error rate; wire
hooks.onPasswordResetFailedto your error tracker so a broken mail transport doesn't silently lock users out of recovery. - [ ] Machine callers declared (cron, OAuth token, API-key routes) in
csrf: { exempt }oncreateAuthHandle, each authenticating itself; for the form-encoded ones alsokit.csrf: { trustedOrigins: ['*'] }insvelte.config.js(SvelteKit's kernel gate, built apps only) with every cookie-auth mutating route still flowing through the handle. See AUTH.md → Machine callers.
CSRF on the client
With csrf.doubleSubmit enabled, createAuthHandle is what sets the urbicon_csrf
cookie and rejects mutating requests without a matching x-csrf-token header — it is not
optional for CSRF. The bundled stores and components already echo the header. For your own
client fetches use the exported csrfFetch:
<script>
import { csrfFetch } from '@urbicon-ui/auth';
async function submit() {
const res = await csrfFetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({/* order fields */})
});
}
</script>Or use withCsrfHeader(init) / readCsrfToken() directly in a custom fetch wrapper.
Cookie/header names are configurable via config.csrf.cookieName / config.csrf.headerName
(pass the same names to the client csrf config).
Stage 3 — Advanced
- Custom persistence adapter — anything beyond Prisma/in-memory (Drizzle, Kysely, raw SQL): follow the Adapter Authoring Guide and validate it against the exported conformance suite so its atomic claims are provably race-safe.
- JWT key rotation — set
jwt.keyId+jwt.previousSecretsto roll the signing secret without logging everyone out; old sessions verify against the previous secret until they expire. - Passkeys (WebAuthn) — wire the
createPasskeyHandlers(deps, webauthn)route group with awebauthn: WebAuthnConfig(pass a persistentchallengeStoreat >1 instance; UV enforcement is on by default —requireUserVerification: falseopts out, and combined withconfig.twoFactorthat makes a passkey login single-factor, which the factory warns about at wiring time; upgrading an app whose users hold UV-less credentials needs the upgrade note first), and drop in<PasskeyManager>+ the passkey entry point on<LoginPage mode="both">. - Notifications & Web Push — register domain events server-side and listen client-side:
// Server: register domain events
import { createNotificationRegistry } from '@urbicon-ui/auth/server';
const registry = createNotificationRegistry();
registry.register({
key: 'order_shipped',
title: (data) => `Order ${data.orderId} shipped`,
url: (data) => `/orders/${data.orderId}`, // ⚠️ untrusted at click time — see note
recipients: async (data) => [data.userId as string] // data is Record<string, unknown>
});<!-- Client: listen + display -->
<script>
import {
NotificationListener,
NotificationCenter,
createNotificationStore
} from '@urbicon-ui/auth';
const store = createNotificationStore();
</script>
<NotificationListener onNotification={(n) => store.add(n)} />
<NotificationCenter
t={en}
notifications={store.notifications}
onMarkAsRead={(id) => store.markAsRead(id)}
/>- Account management (self-service) — let a signed-in user manage their own account. Mount the four handlers under
/api/auth/account/*and drop in<AccountSettings>:
// src/routes/api/auth/account/change-password/+server.ts
import { createChangePasswordHandler } from '@urbicon-ui/auth/server';
import { authDeps } from '$lib/server/auth-setup';
export const { POST } = createChangePasswordHandler(authDeps);
// …and change-email, profile, delete the same way; plus a verify-email-change
// route (createVerifyEmailChangeHandler) behind the link sent to the new address.<script>
import { AccountSettings } from '@urbicon-ui/auth';
let { data } = $props(); // data.user from your load fn (locals.user)
</script>
<AccountSettings user={data.user} onDeleted={() => goto('/')} />All four mutations are re-auth gated (current password). change-password keeps the
current device signed in while logging out every other session; change-email verifies the
new address and is account-enumeration safe (always "check your inbox"); delete-account
hard-deletes and fires hooks.onBeforeAccountDelete before erasing so you can archive.
- Active-session listing — show the user their sessions and let them sign devices out. Requires
refreshTokenrotation (a session is a token family). Mount the route group and drop in<SessionManager>:
// src/lib/server/auth-setup.ts
import { createSessionsHandlers } from '@urbicon-ui/auth/server';
export const sessions = createSessionsHandlers(authDeps);
// src/routes/api/auth/sessions/+server.ts
export const GET = sessions.list.GET;
// + sessions/revoke/+server.ts → export const POST = sessions.revoke.POST;
// sessions/revoke-others/+server.ts → export const POST = sessions.revokeOthers.POST;<script>
import { SessionManager } from '@urbicon-ui/auth';
</script>
<SessionManager apiPath="/api/auth/sessions" />Revokes are ownership-scoped (a guessed family id can't sign out another user). The IP is shown only if you set config.sessions = { storeIp: true } (GDPR opt-in); the user-agent alone drives the "Browser · OS" device label.
- Two-factor (TOTP) — add an authenticator-app second factor. Set
config.twoFactor(theencryptionKeyis required — high-entropy, stable, e.g. 32 random bytes base64), providerepos.backupCode(the shipped adapters include it), mount the route group, and add<TwoFactorManager>for enrolment plus the verify path the two-step<LoginPage>posts to:
export const authDeps = createAuthDeps({
config: {
/* …jwt, appUrl… */
twoFactor: { encryptionKey: process.env.TWO_FACTOR_KEY! } // required when 2FA is on
},
repos, // must include repos.backupCode (createInMemoryRepos / createPrismaRepos do)
email
});// src/lib/server/auth-setup.ts
import { createTwoFactorHandlers } from '@urbicon-ui/auth/server';
export const twoFactor = createTwoFactorHandlers(authDeps);
// src/routes/api/auth/account/2fa/setup/+server.ts
export const POST = twoFactor.setup.POST;
// + account/2fa/enable → twoFactor.enable.POST,
// account/2fa/disable → twoFactor.disable.POST, and the PUBLIC
// 2fa/verify route → twoFactor.verify.POST — the second login step.<script>
import { TwoFactorManager } from '@urbicon-ui/auth';
let { data } = $props(); // data.user from locals.user; user.totpEnabled drives the UI
</script>
<TwoFactorManager user={data.user}>
{#snippet qr({ uri })}<MyQrCode value={uri} />{/snippet}
</TwoFactorManager>Setup returns the otpauth:// URI + Base32 secret (the core ships no QR encoder to stay zero-dep — render it via the qr snippet, or let the user enter the key manually). Enrolment is two-step (setup → confirm a code), and enabling returns one-time backup codes. The secret is stored AES-256-GCM-encrypted; disable is password re-auth gated. The login handler gates automatically on user.totpEnabled — no extra wiring. Passkey logins are not gated, which rests on webauthn.requireUserVerification being enforced (its default): without UV a passkey is possession alone, and a passkey login would be single-factor for a TOTP user. encryptionKey has no rotation overlap — changing it locks every TOTP user out and blocks re-enrolment, leaving a backup code — or a passkey, which is not TOTP-gated — as the way in (key-rotation runbook). createAuthDeps injects a strict rateLimit.twoFactor default for the brute-force-critical verify step. The verify route must be public (default public routes already cover /api/auth/); make sure your route guard doesn't require a session for it.
- Federated identity / SSO — one deployment becomes the identity provider (ES256 tokens +
createJWKSHandlerserving the JWKS), sibling apps under the same parent domain verify withcreateFederatedAuthHandleand decide access themselves inresolveUser(identity ≠ authorization — the IdP'srolenever crosses the boundary). Setup for both sides, the key-rotation runbook, and the deliberate current-scope limits: AUTH.md → Federated Identity (SSO).
Security notes worth pinning
notification.urlis untrusted at navigation time. It originates from your event registry, but treat it as data: validate/allow-list it before passing it togoto()so a crafted URL can't drive an open redirect.- The console email transport is dev-only. It logs full email bodies (including reset/verify tokens) to stdout — never ship it to production.
createAuthHandleis mandatory for CSRF and session hydration. Route handlers alone don't apply the Origin/Double-Submit checks or setlocals.user. The Origin check covers only requests that reach the handle — in asequence(), an earlier handle that returns a response without callingresolve(maintenance mode, webhook shortcut, redirect) bypasses it for its routes; withtrustedOrigins: ['*']set, nothing else covers those. A machine route that must accept Origin-less POSTs belongs incsrf: { exempt }, not outside the handle; a form-encoded one is additionally gated by SvelteKit's own kernel CSRF check, which403s it before any hook (in built apps only, never undervite dev) — see AUTH.md → Machine callers.- Notification mark-read / delete must scope by the authenticated user. In those route handlers derive
userIdfromlocals.user, never from the request body — otherwise one user can mutate another's notifications (IDOR). recipients: 'admins'needs a resolver. The package has no role model, so passresolveAdminRecipients(e.g.() => repo.findAdminUserIds()) tocreateNotificationServicefor any type that targets admins. Without itsend()throws rather than silently dropping the alert. Push-delivery failures are swallowed (one bad subscription mustn't break a send) — passonPushResultto observe them; dead endpoints (410/404) are pruned automatically.
Prisma Schema
See prisma/auth-schema.prisma for the reference schema — it ships in the package, at node_modules/@urbicon-ui/auth/prisma/auth-schema.prisma. Ten models: User, Invitation, PushSubscription, Notification, NotificationType, NotificationPreference, Passkey, RefreshToken, TwoFactorBackupCode, plus the optional consumer-side FederatedAccount link table for SSO. Copy/merge into your app's schema.
Tests
Unit tests (Vitest) cover the crypto primitives (JWT, HMAC, PBKDF2, CBOR, WebAuthn parsing, TOTP/HOTP/Base32 against the RFC-6238/4226 vectors, AES-256-GCM secret encryption), CSRF, rate-limiter, session cookies, validation, notification registry/SSE/Push, auth handlers (incl. the 2FA setup/enable/disable/verify flow + login gate), security headers, the service-worker notification-click handler, and the adapter conformance suite (atomic claim/scope guarantees — including backup-code single-use — run against both the in-memory and Prisma adapters).
cd packages/auth && bunx --bun vitest runFull WebAuthn attestation/assertion against a real authenticator, end-to-end browser coverage, and integration tests against a live Prisma instance remain out of scope for now — see AUTH.md → Production-Readiness Checklist.
Known Limitations
The three most load-bearing for a production deploy are below; the full catalog (grouped by defense-in-depth, account-enumeration/timing, and rate-limiting/route-scope, each with its rationale) is the single source of truth in AUTH.md → Known Limitations — kept there to avoid a drifting second copy.
- Persistent stores are opt-in. Challenge, rate-limit, and refresh-token stores all default to in-memory (single-process). Pass a
ChallengeStore/RateLimitStore/RefreshTokenRepository(Redis/Prisma/Upstash) when running >1 instance — the Prisma adapter is bundled. - CSRF Double-Submit and refresh-token rotation are opt-in. The handle's Origin check is always on; the token layer (
config.csrf = { doubleSubmit: true }, requires header-capable clients — incompatible with remote-function / no-JS-form mutations) and rotation (config.refreshToken = {}+repos.refreshToken) are additive production hardening. publicRoutesreplaces the defaults, and a string entry is a prefix. Passing the option drops the built-in list instead of adding to it.'/api/auth/'is in that list, so an override that omits it guards the app's own sign-in —POST /api/auth/loginthen answers401to a visitor who has no session. Spread the exportedDEFAULT_PUBLIC_ROUTESto extend ([...DEFAULT_PUBLIC_ROUTES, '/pricing']); replace wholesale only for a handle scoped to routes that mount no auth endpoints. A string matches withstartsWith:'/api/auth/'exempts every sub-route below it,'/pricing'also exempts/pricing-adminand/pricing/internal, and'/'exempts the whole app — the obvious spelling of "my landing page is public" turns the guard off entirely, which the handle warns about at construction. One pathname alone is the object form:{ path: '/', exact: true }publishes the landing page and nothing under it. A list held in a variable first needsas constor the annotationPublicRoute[]— TypeScript otherwise widensexact: truetobooleanand the assignment is a type error; an inline list needs nothing. Don't nest protected app routes under a public prefix.
Roadmap
The production-readiness milestone is shipped and stable (persistent-store adapters,
refresh rotation, CSRF, atomic adapter contract + conformance suite). The scope-conform
account clusters — account management, active-session listing, TOTP two-factor,
invitation management, passkey management, and Federated Identity / SSO
(createFederatedAuthHandle + createJWKSHandler, ES256 + JWKS) — have also shipped
(beta). Remaining hardening candidates live in the
Known-Limitations catalog.
Development
bun --filter='@urbicon-ui/auth' run build # svelte-package
bun --filter='@urbicon-ui/auth' run check # svelte-check
cd packages/auth && bunx --bun vitest run # testsRelated
- docs/AUTH.md — architecture, security-gap catalog, consumer-migration notes; ships in this package, rendered at https://ui.urbicon.de/auth/guide
