@rubriclab/rubrot-auth
v0.2.3
Published
Passkeys, magic links, invites, recovery codes and sessions for Next 16 apps — storage-adapter based, zero lock-in
Keywords
Readme
@rubriclab/rubrot-auth
Passkeys, magic links, invites, recovery and DB-backed sessions for the fleet's Next 16 apps (rubrot, metal, vault, uptick). Ships as TypeScript source — consume via transpilePackages: ["@rubriclab/rubrot-auth", "@rubriclab/rubrot-ui"] or bun natively. Hardened semantics from rubrot, folded together with metal/vault/uptick's doors: transactional session rotation, SQL-side idle/absolute expiry, atomic single-use challenges and links, enumeration-quiet everywhere, sha256-only at rest, one live link per user.
Shape
@rubriclab/rubrot-auth createAuth(config) → { sessions, passkeys, links, team, stepUp }
@rubriclab/rubrot-auth/next createAuthRoutes(auth), presenceMiddleware, jsonError
@rubriclab/rubrot-auth/postgres createPostgresStore(sql, opts), ensureSchema, schemaSql
@rubriclab/rubrot-auth/client browser ceremony + fetch helpers, defaultAuthApiPaths
@rubriclab/rubrot-auth/react LoginForm, PasskeysCard, PasskeyNudge, TeamCard,
AcceptInviteCard, RecoverCard, useStepUp, StepUpDialogConfig
createAuth({
store, // AuthStore adapter (postgres or your own)
mailer, // { send(msg) → {sent:true}|{sent:false,error} , health?() }
rp: { name, publicUrl }, // or (request) => ({ name, publicUrl }) — rpID = hostname
cookie: { name, tokenPrefix, secure? }, // secure defaults true; false = dev escape
userVerification?, // "required" (default) | "preferred"
ttls?, // session 14d absolute / 3d idle / 5min touch,
// challenge 5min, magic 15min, invite 7d, recovery 15min
features?: { magicLinkLogin, invites, recovery, passkeyNudge },
// defaults: rubrot posture — passkey-only, invites+recovery on
paths?, // magicConsume "/api/auth/link", acceptInvite "/accept-invite",
// recover "/recover", afterLogin "/", login "/login"
})The mailer port is transport-only — the package never owns mailbox OAuth. mailer.health() (optional) feeds the login form's honesty flags (delivered/healthy), global state that never enumerates accounts. Email bodies are pure functions (inviteEmail, recoveryEmail, magicLinkEmail): explicit white background (dark-mode clients paint their own canvas otherwise) and a saturated CTA fill (neutral fills get repainted into white-on-white).
Route wiring (per app, once)
// lib/auth.ts
export const auth = createAuth({ store: createPostgresStore(sql), mailer, rp, cookie });
export const authRoutes = createAuthRoutes(auth);
// app/api/auth/passkey/login/options/route.ts
import { authRoutes } from "@/lib/auth";
export const dynamic = "force-dynamic";
export const { POST } = authRoutes.passkeyLoginOptions;Handler → file map (matches defaultAuthApiPaths in ./client): passkeyLoginOptions|passkeyLoginVerify → passkey/login/options|verify, passkeyRegisterOptions → passkey/register/options, passkeys (GET/POST) → passkeys, passkey (PATCH/DELETE) → passkeys/[id], magicLinkRequest → link/request, magicLinkConsume (GET redirect) → link, inviteOptions|inviteVerify → invite/options|verify, recoveryRequest|recoveryVerify → recovery, recovery/verify, plus logout and session. Disabled features answer 404. Team endpoints are app-wired: point your /api/team* handlers at auth.team.* (the react TeamCard defaults to /api/team, /api/team/invites[/:id], /api/team/members/:id, /api/team/resend). Step-up: auth.stepUp.begin(user.id) in your POST, auth.stepUp.verify({ userId, challengeId, response }) before the gated read — wrong-owner assertions are 403.
Edge gate (UX, not the security boundary — real verification stays server-side):
// middleware.ts
export default presenceMiddleware({ cookieName: "metal_session", publicPaths: ["/api/health"] });Errors: core throws AuthError(status, code, message); handlers map via jsonError to { error, code }.
Postgres adapter
createPostgresStore(sql, opts) needs postgres(url, { transform: postgres.camel }). Schema DDL is exported two ways: await ensureSchema(sql, opts) (idempotent, metal/vault/uptick's in-code style) and schemaSql(opts) (one string for rubrot's migration framework). Both create missing tables AND alter … add column if not exists the columns the package needs (rp_id, revoked_at, invited_by, last_seen_at) — adopting an existing schema is a config choice, never a data move.
Options: tablePrefix, per-table tables overrides, disabledStatus (what removal writes), userInsertDefaults (extra NOT NULL columns on upsert, e.g. name), userColumns (extra camelCase columns selected onto the session user).
Per-app adoption
| | rubrot | metal | vault | uptick |
|---|---|---|---|---|
| cookie.name | rubrot_admin_session | metal_session | vault_session | uptick_session |
| cookie.tokenPrefix | admin_session | metal_session | vault | uptick |
| store opts | tablePrefix: "admin_" | disabledStatus: "removed", userInsertDefaults: { role: "member" }, userColumns: ["role"] | disabledStatus: "removed", userInsertDefaults: name+role, userColumns: ["name","role"] | same as vault |
| tables hit | admin_users/_sessions/_passkeys/_auth_challenges/_invites/_recovery_tokens | users/sessions/passkeys/auth_challenges/login_links | same as metal | same as metal |
| features | invites+recovery (defaults) | { magicLinkLogin: true, invites: false, recovery: false } | same as metal | metal's + passkeyNudge: true |
| userVerification | required (default) | required | preferred (keeps current behavior) | preferred |
| rp | Rubrot / RUBROT_ADMIN_PUBLIC_URL | Metal / METAL_PUBLIC_URL | Vault / VAULT_PUBLIC_URL | radar / UPTICK_PUBLIC_URL |
| extras | — | — | stepUp gates reveals | mailer.health ← mailStatus(); nudge dismissal via server action prop |
Notes: session/link tokens verify by sha256 hash, so live sessions survive the cutover regardless of prefix. Old vault/uptick challenge rows use register/authenticate kinds — challenges live 5 minutes, nothing to migrate. Bootstrap (auth.links.createAdminSignInLink) refuses once anyone can sign in unless force; passkey-only apps get an invite-ceremony link instead of a raw sign-in link.
Tests
bun test packages/auth — the suite runs the full core against a pure in-memory store (tests/fakes.ts) with deterministic WebAuthn ceremonies injected through the webauthn config seam; cookieJar is the matching seam for next/headers.
