Auth concepts
StoryShelf ships one auth engine — @storyshelf/auth built on Better Auth. Better Auth owns OIDC/SAML/WebAuthn wire protocols; StoryShelf owns users, user_invite_tokens, project_members, and the UI. Sessions are Better Auth DB rows under the storyshelf_session cookie (seven-day TTL, cookieCache off so revocation bites immediately). Engine identities mirror into the shelf users table with roles preserved — see the Better Auth hooks overview.
Engine vs shelf
Section titled “Engine vs shelf”| Concern | Owner | Notes |
|---|---|---|
user / session / account / verification / passkey / ssoProvider | Better Auth tables via opaque bridge | DatabaseAdapter.transact? gives atomic consumeOne / incrementOne; see custom adapters |
users / user_invite_tokens / project_members | StoryShelf | users is the source of truth for roles, memberships, display_name_override |
| Secrets | resolveAuthOptions + {env:NAME} refs | process.env first, then your resolveSecrets hook (Vault/AWS/GCP) |
Login is descriptor-driven
Section titled “Login is descriptor-driven”createShelfAuth({ db, secret, baseURL, social, oauth, sso, passkeys, plugins }) describes methods:
// each entry drives one widget on /auth/login{ kind: "password" | "oauth" | "passkey" | "sso", id: string, label: string }kind:"password"→ email/password form (POST /auth/engine/login).kind:"oauth"→ one button per native social (githubPreset,gitlabPreset, etc. — social providers) or enterprise OIDCkeycloakPreset/oktaPreset/auth0Presetvia generic OAuth.kind:"sso"→ domain-routed OIDC/SAML via the SSO plugin (ssoKeycloakPreset,samlPreset).kind:"passkey"→ WebAuthn button (passkey plugin) — needs HTTPS orlocalhost.
Adding a new provider is a preset (pure config) — no UI code. Sole oauth/sso method auto-redirects from /auth/login to the IdP.
Sessions
Section titled “Sessions”- Better Auth session rows:
token(raw),token.signaturein the cookie,expiresAt,ipAddress,userAgent. See session management and session options. advanced.database.generateIdis ULID (shared withusers),advanced.cookies.session_token.nameisstoryshelf_session. See advanced options.- Mount:
app.all("/api/auth/*", shelf.handler)— Hono integration. Login/profile pages callshelf.api.getSessionserver-side;cookieCacheis off.
Invite-only local accounts
Section titled “Invite-only local accounts”No self-registration. Admins call shelf.adapter.issueInvite({ email, name, role }) → one-time link /auth/invites/<inviteId>?token=<token> (relay out-of-band; fictional addresses like front-desk@internal work because emails are identifiers, not mailboxes). Tokens are sha256-hashed at rest, single-use, 7-day TTL, superseded on re-issue — re-invite is also password recovery. acceptInvite writes engine user + credential account rows directly with Better Auth’s hasher. Dev/fly servers bootstrap an env admin via ensurePasswordAdmin (AUTH_PASSWORD ≥12, AUTH_EMAIL optional).
Passkeys
Section titled “Passkeys”Enable with passkeys: {} — RP ID and origin derive from baseURL. Users enroll on /profile (register a 2nd key as backup) and sign in from login via navigator.credentials ceremonies (passkey-ceremony.ts). Passkeys need a secure context — the UI hides buttons on plain HTTP.
Memberships and roles
Section titled “Memberships and roles”First-seen engine users are upserted as member and group-synced per project; existing roles are never demoted. Project roles are viewer/developer/approver/admin; site admin bypasses project checks. See Roles.
Reads without a session
Section titled “Reads without a session”Besides public builds, one route reads a build without a session: GET /_s/<token>/<file>, the signed Storybook URL a remote browser opens when it cannot route back to the server (Remote browsers). The token is an HMAC under a key derived from secret, names one project and one build, expires (derived from the build’s size, at least 2 hours and never more than 24; a project can set its own) and grants the build’s static files only: no pages, no API, no other build. The route exists only with testing, answers every refusal with the same 404, and the token is kept out of logs and traces. A served file answers Access-Control-Allow-Origin: *, as a public build’s files do, so a sandboxed preview frame can load it; the URL is the credential and no cookie or header is involved. Each client IP is limited to 6000 requests a minute on the route (testing.storybookUrlRateLimit). Rotating secret without previousSecret ends every outstanding URL at once.
better-auth@1.7.6, @better-auth/passkey@1.7.6, @better-auth/sso@1.7.6 — see the changelog.