Skip to content

Auth concepts

View as Markdown

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.

ConcernOwnerNotes
user / session / account / verification / passkey / ssoProviderBetter Auth tables via opaque bridgeDatabaseAdapter.transact? gives atomic consumeOne / incrementOne; see custom adapters
users / user_invite_tokens / project_membersStoryShelfusers is the source of truth for roles, memberships, display_name_override
SecretsresolveAuthOptions + {env:NAME} refsprocess.env first, then your resolveSecrets hook (Vault/AWS/GCP)

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 OIDC keycloakPreset/oktaPreset/auth0Preset via generic OAuth.
  • kind:"sso" → domain-routed OIDC/SAML via the SSO plugin (ssoKeycloakPreset, samlPreset).
  • kind:"passkey" → WebAuthn button (passkey plugin) — needs HTTPS or localhost.

Adding a new provider is a preset (pure config) — no UI code. Sole oauth/sso method auto-redirects from /auth/login to the IdP.

  • Better Auth session rows: token (raw), token.signature in the cookie, expiresAt, ipAddress, userAgent. See session management and session options.
  • advanced.database.generateId is ULID (shared with users), advanced.cookies.session_token.name is storyshelf_session. See advanced options.
  • Mount: app.all("/api/auth/*", shelf.handler) — Hono integration. Login/profile pages call shelf.api.getSession server-side; cookieCache is off.

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).

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.

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.

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.