# Auth concepts

> How StoryShelf authenticates users — engine, sessions, invites, and per-branch memberships.

Source: https://storyshelf.js.org/concepts/auth/

StoryShelf ships one auth engine — [`@storyshelf/auth`](../../packages/auth/) built on [Better Auth](https://www.better-auth.com/docs). [Better Auth](https://www.better-auth.com/docs) 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](https://www.better-auth.com/docs/concepts/hooks#databasehooks) overview.

## 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](https://www.better-auth.com/docs/concepts/database#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

`createShelfAuth({ db, secret, baseURL, social, oauth, sso, passkeys, plugins })` describes methods:

```ts
// 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](https://www.better-auth.com/docs/reference/social-providers)) or enterprise OIDC `keycloakPreset`/`oktaPreset`/`auth0Preset` via [generic OAuth](https://www.better-auth.com/docs/plugins/generic-oauth).
- `kind:"sso"` → domain-routed OIDC/SAML via the [SSO plugin](https://www.better-auth.com/docs/plugins/sso) (`ssoKeycloakPreset`, `samlPreset`).
- `kind:"passkey"` → WebAuthn button ([passkey plugin](https://www.better-auth.com/docs/plugins/passkey)) — 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.

## Sessions

- Better Auth session rows: `token` (raw), `token.signature` in the cookie, `expiresAt`, `ipAddress`, `userAgent`. See [session management](https://www.better-auth.com/docs/concepts/session-management#cookie-cache) and [session options](https://www.better-auth.com/docs/reference/options#session).
- `advanced.database.generateId` is ULID (shared with `users`), `advanced.cookies.session_token.name` is `storyshelf_session`. See [advanced options](https://www.better-auth.com/docs/reference/options#advanced).
- Mount: `app.all("/api/auth/*", shelf.handler)` — [Hono integration](https://www.better-auth.com/docs/integrations/hono). Login/profile pages call `shelf.api.getSession` server-side; `cookieCache` is off.

## 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](https://www.better-auth.com/docs/concepts/email-and-password). Dev/fly servers bootstrap an env admin via `ensurePasswordAdmin` (`AUTH_PASSWORD` ≥12, `AUTH_EMAIL` optional).

## 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

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](../roles/).

## 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](https://storyshelf.js.org/guides/remote-browsers/#signed-storybook-urls)). 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.

## Pins

`better-auth@1.7.6`, `@better-auth/passkey@1.7.6`, `@better-auth/sso@1.7.6` — see the [changelog](https://www.better-auth.com/docs/changelog).
