Skip to content

Authentication

How the web app authenticates against the API and CouchDB, end to end.

Access and refresh tokens live in httpOnly cookies set by the API. JavaScript — including any script injected through an XSS bug — can never read them, so a compromised page cannot exfiltrate a long-lived credential.

On top of that, the refresh token is sender-constrained: at sign-in the client generates a non-extractable ECDSA P-256 keypair (WebCrypto) and registers the public half with the server. Refreshing requires a proof signed by the private key, which the browser will sign with but never reveal. A stolen refresh cookie alone therefore cannot mint tokens, and deleting the key is a fail-closed, offline-capable logout: without it the session cannot outlive the current access token.

The web app and the API must share the hostname (ports may differ — cookies ignore ports, and same-host-different-port is same-site, which SameSite=Lax covers). Different hostnames for web and API are not supported.

Cookie httpOnly Path Purpose
oboku_access_token ✅ / 5-minute access JWT; reaches every REST route and the /couchdb proxy
oboku_refresh_token ✅ /auth rotating refresh token; only sent to /auth/token and /auth/logout

Both are SameSite=Lax, host-only (no Domain), and Secure whenever the request came over https (direct or via X-Forwarded-Proto). The access token is a plain 5-minute bearer — no per-request crypto, so replication and the service worker are untouched. The refresh cookie's value changes on every refresh (rotation) and carries the refresh-token TTL as Max-Age.

sequenceDiagram
    participant W as Web app
    participant A as API
    W->>W: generate non-extractable ECDSA P-256 keypair (pending slot)
    W->>A: POST /auth/signin/email {credentials, installation_id, public_key}
    A->>A: issue access JWT + refresh session (public_key bound)
    A-->>W: Set-Cookie access + refresh, JSON body = session metadata (no tokens)
    W->>W: promote keypair pending → current, persist token-less profile

All session-issuing routes (signin/email, signin/google, magic-link/complete) accept the optional public_key JWK and bind it to the refresh session (refresh_tokens.public_key). The keypair is staged under a pending slot and only promoted to current after the server has bound it, so a failed sign-in attempt never destroys the active session's key. The JSON body carries only session metadata (dbName, email, nameHex, and the freshly minted sessionId) — the tokens travel exclusively in Set-Cookie and never appear in a response body, so JS (and therefore XSS) cannot read them. The web app persists this token-less session and keeps the sessionId to revoke exactly this session at logout.

The browser attaches oboku_access_token automatically ( credentials: "include" ):

  • REST: the auth guard reads the cookie first and falls back to Authorization: Bearer (admin app).
  • CouchDB: the /couchdb proxy translates the cookie into the Authorization: Bearer header CouchDB validates itself, keeps an already-present header untouched (admin), and strips cookies from what it forwards upstream.
  • Service worker: inherits the cookie like any other client — the old auth message-passing between worker and main thread is gone. A background 401 simply propagates; the main thread refreshes on its own traffic.

CORS splits by how a client authenticates. Cookie origins — any port on the web hostname — are reflected with credentials enabled, and a defense-in-depth middleware refuses cookie-authenticated mutations whose Origin is not one of them (CSRF; SameSite=Lax is the primary layer). The admin panel authenticates with a Bearer token, so /admin/* is reflected without Access-Control-Allow-Credentials and takes its origin from ADMIN_PUBLIC_URL, defaulting to the cookie origins. Withholding credentials there keeps a compromised admin host from acting as a signed-in reader.

sequenceDiagram
    participant W as Web app
    participant A as API
    W->>A: request with expired access cookie
    A-->>W: 401
    W->>W: sign DPoP-style proof {htm, htu, iat, jti} with the current key
    W->>A: POST /auth/token (refresh cookie + DPoP header)
    A->>A: verify proof signature + thumbprint against bound public_key, iat freshness
    A->>A: rotate refresh token (successor inherits public_key)
    A-->>W: Set-Cookie fresh access + rotated refresh
    W->>A: retry original request (once)

The refresh credential is the cookie, exclusively — no token in the URL or body. For a session with a bound public_key, /auth/token requires the DPoP header: a short JWT signed by the private key, verified with jose (signature by the embedded JWK, JWK thumbprint equal to the bound key, htm = POST, iat within a ±5-minute window; htu is deliberately not enforced because reverse proxies make the reconstructed URL unreliable). Concurrent 401s are collapsed into one in-flight refresh per client, and a request that 401s after someone else already refreshed is retried without triggering another refresh (refresh epoch).

Rotation semantics are unchanged: every refresh mints a successor (which inherits the bound key), the previous token stays valid through a one-hour grace window for lost responses and multi-tab races, and a replay past the grace window is refused without collapsing the chain.

Fail-closed and offline-capable, in three steps (useSignOut):

  1. Delete the proof keys from IndexedDB. From this instant nothing can refresh the session — even offline, it dies when the ≤5-minute access cookie expires.
  2. Clear local state (query cache, active profile, plugin state). The UI is signed out immediately; the profile row stays as a loggedOut tombstone. Other tabs reset their session queries when they see the active profile change, so their auto-persist cannot write the account's data back to the offline snapshot all tabs share.
  3. Best-effort server revocation: the tombstone sweep (RevokeLoggedOutProfiles, on boot / online / post-sign-out) calls POST /auth/logout with the tombstone's sessionId in the body — a non-secret identity, not the refresh cookie. The server revokes that whole Postgres chain by id and leaves the cookies untouched (there is no cookie-clearing path): they may already belong to a newer session, and the revoked rows make any lingering cookie inert. Only after the call succeeds does the sweep delete the local tombstone row. Offline sign-outs are retried until they succeed.

Revoking by sessionId is why the single cookie jar is not a problem: the sweep names the exact session to kill regardless of which refresh cookie is currently stored, so signing into another account — or the same one — before the sweep completes cannot misfire. A re-login mints a fresh sessionId on a new chain (after wiping the old one), so an old tombstone can never name the new session; it simply finds nothing to revoke.

/auth/logout is therefore @Public() and authenticates nothing — an accepted, deliberate tradeoff. Every possession proof is already gone by the time the sweep runs: the proof key was deleted in step 1, the refresh cookie may name a newer session, and the access cookie may be expired or the client offline. The revoke must be an unauthenticated, cookie-independent revoke-by-identity or it cannot meet its fail-closed/offline goals. The consequence is that anyone who obtains a session's sessionId can force that one session to re-authenticate. This is bounded to availability, not access: revokeBySessionId deletes a single chain (one installation, not the account); sessionId is a 122-bit UUID, so it is not guessable and must be leaked per-victim (sign-in body captured in logs/analytics, shared-device Dexie, or XSS — which already owns the origin and is strictly stronger); and the outcome is a forced re-login with all local data intact. The residual leak vectors are minimised by keeping sessionId out of server logs and, if abuse is ever observed, rate-limiting the endpoint.

useForgetAccountSessions returns a function that drops the temporary master key and runs every plugin's sign-out. Each change of account calls it before anything renders for the next account. A plugin whose sign-out throws is logged, and the others still sign out. OneDrive's sign-out finishes asynchronously.

  • Sign-out: useSignOut calls it before clearing the active profile id.
  • Sign-in: completeAuthentication, which receives it from useSignIn or useCompleteMagicLink, calls it unless the account is already the active one. A relogin to the same account keeps the sessions, as it keeps the local database. The relogin screen accepts any account, so a relogin can still switch accounts. A sign-in after a sign-out forgets the sessions again: a plugin login still running at sign-out may have stored one in between.
  • Another tab: on each profiles broadcast, SyncProfilesAcrossTabs calls it when the active profile id it adopts from localStorage differs from this tab's.

An account switch recreates the local database but keeps the downloaded files: purging them would also destroy the device-local (file plugin) books, and only sign-out warns about that.

Sessions live in Postgres (refresh_tokens): one row per issued token, hashed (SHA-256), chained per (user_id, installation_id). Relevant columns:

  • token_hash — the only lookup key.
  • public_key — JSON-serialized public JWK bound to the session at sign-in (null for sessions issued before sender constraining shipped; those are rejected on their next refresh and must re-authenticate — no graceful migration).
  • superseded_at / successor_token — rotation bookkeeping; the encrypted successor converges concurrent refreshes and is why the API must run as a single instance.

Individual tokens expire after ~6 months (created_at anchored); a daily cron deletes expired and long-superseded rows.

The admin app is a separate surface with its own endpoints under /admin (guarded by AdminAuthGuard, not the user cookie flow): POST /admin/signin and POST /admin/refresh return access_token/refresh_token in the JSON body and take { refresh_token } in the body, and the admin app stores them in localStorage and sends Authorization: Bearer. The user guard's cookie-first read keeps that Bearer fallback for it. None of this touches the user /auth/* endpoints, which never put a token in a response body; in particular /auth/logout revokes by session_id and has no refresh_token body.

Pre-cookie web sessions are not migrated: after the cutover, the first authenticated request fails, and the user re-logs in once with all local data intact (same-account sign-in does not recreate the local database, and the sign-in wipes the old server chain). Tokens persisted by old bundles are dropped, not reused. A loggedOut tombstone written by a pre-sessionId bundle has no identity to revoke by, so the sweep cannot kill its chain from the client; that chain lapses through the refresh TTL and the stale-session cron instead (and it is rejected the moment it tries to refresh).

There is deliberately no old-bundle back-compat: /auth/token accepts no refresh token outside the cookie, so a browser still running a pre-cookie bundle cannot refresh once the new API is live. Those users are signed out until the PWA picks up the new bundle — which it does on the next visit — and then re-login once. The accepted trade-off is a short lockout instead of carrying token-in-URL transports.

Deploy the API and the web together (API first within the release): a new web bundle needs an API that sets cookies, and the old bundles' lockout window starts when the API ships.