Authentication overview
Authentication is provider-neutral at the application boundary. A provider returns a stable subject, verified email, and display name. Stowplan uses the provider and subject as the authority for an identity, then issues its own opaque session. An email match never links a new provider identity automatically.
The Stowplan session lifetime is configured independently from Google's web session and any Cloudflare Access session. A provider or perimeter session can therefore remain active after the Stowplan session ends, or end while local Stowplan data remains available on the device.
Supported adapters:
| Adapter | Purpose | Production status |
|---|---|---|
| Google OAuth 2.0/OIDC | Public ordinary-account sign-in, identity linking, and reauthentication | Only production ordinary-account provider |
| Cloudflare Access JWT | Independent administrator perimeter and disabled-by-default migration exchange | Never an ordinary-account provider or authority source |
| Invite URL | Enroll a persistent viewer or editor | Fully automated |
| Development | Synthetic test personas on isolated hosts | Explicitly enabled and always forbidden on the production hostname |
Every Google OAuth start uses a same-origin POST whose fresh Turnstile token is validated before transaction allocation. This applies to public sign-in, linking a first or additional Google identity, and reauthentication. OAuth uses authorization code + PKCE, an OIDC nonce, a browser-bound cookie, and single-use, ten-minute state records. OAuth-start responses are uncached. Return paths are bounded, decoded until stable for safety inspection, and rejected rather than persisted when they are malformed, excessively nested, cross-origin, or conceal an invitation route. Google ID tokens are signature-, issuer-, audience-, time-, nonce-, subject-, and verified-email-checked. The optional azp claim must match the configured client whenever it appears and is required for a multi-audience token. Sessions are random opaque values; only a SHA-256 hash is stored. The session cookie uses the __Host- prefix and is HttpOnly, Secure, SameSite=Lax, and revocable. Duplicate session-cookie values are refused, and workspace authorization is checked on every protected API.
Turnstile is intentionally limited to Google OAuth initiation for launch. OAuth callbacks already require a browser-bound single-use transaction, guest redemption requires both a signed-in account and a single-use invitation, and workspace and admin APIs require their own server authorization. Those paths use bounded bodies, durable quotas, scoped circuits, and Cloudflare edge controls instead of adding another interactive challenge. Add another challenge only in response to measured abuse on a specific public allocation path.
Signed-in users can review every retained app session for their own account from Account. The bounded list identifies the current session and shows creation, expiry, revocation, approximate last server activity, the browser-provided user agent, and an anonymized network prefix when available. Last activity is updated at most about once every five minutes while an authenticated device reaches the server. Offline and device-only use is not visible until that device makes another server request.
Sign out this session revokes the current app session and clears its cookie. A user can also revoke any other active session belonging to the same account. Revocation removes server access without deleting that device's local replicas, pending commands, or blocked commands. Disabling a user atomically revokes all of that user's active sessions and active unused guest links and prevents another session from being issued; enabling the account permits a later sign-in but never revives those sessions or links.
The account session list never returns a raw cookie value or stored session hash. Revoked and expired session rows remain available for operational review until bounded cleanup removes rows whose original expiry is at least 30 days old.
Public signup does not use an email allowlist or invitation admission gate. Every newly created Google-backed account starts with the global user role and no workspace membership. Installation-wide account creation, per-account sessions, linked identities, memberships, workspace allocation, stored snapshot bytes, and guest-link creation have durable launch limits. Independent circuit breakers can pause new accounts, new workspaces, snapshot growth, guest-link creation, or guest-link redemption without weakening authorization or preventing local-only work and export. A signed-in account may deliberately link a first or additional Google identity through an OAuth transaction bound to its active user and session IDs. The Account page distinguishes those actions with a server-derived boolean that does not reveal provider subjects, linked emails, identity IDs, or counts. A sensitive operation may request Google reauthentication, which requires an already linked Google subject for the exact current user and records a fresh server-held proof timestamp on the exact active session. It does not create a session or consume the ordinary issuance budget. Ordinary authentication never grants global-admin or workspace authority.
The legacy POST /api/auth/access exchange is unavailable unless AUTH_ACCESS_MIGRATION_ENABLED=true. When temporarily enabled before a cutover, the signed-out Account page presents an explicit recovery action for a request already carrying a valid Access assertion. The exchange can issue a two-hour migration session only for an already linked Cloudflare Access subject. It cannot create an account, link by email, or appear in ordinary provider discovery. The session records cloudflare-access provenance. After recovery, explicitly link Google from Account and verify a direct Google sign-in. Before making Account public, disable the exchange, use the global-admin bulk control to revoke all active pre-Google sessions, including legacy active sessions without recorded provenance, and verify the admin inventory reports zero.
New invite URLs use /guest with the single-use token and optional workspace return path in the URL fragment after #. Browsers do not transmit the fragment in the HTTP request path, query string, or Referer header. Mail previews and security scanners can therefore inspect the fixed confirmation page without receiving or consuming the token. An invited guest is an ordinary signed-in account that gains only the persistent viewer or editor membership encoded by the link; invitation redemption cannot create a session, grant ownership, or grant global administration.
If sign-in is required, the browser stores the fragment only in same-tab session storage. OAuth continuation carries the fixed /account?resume=invitation route rather than the credential. After the recipient explicitly accepts, the browser sends the token in the bounded same-origin JSON body of POST /api/auth/guest. Confirmation atomically attaches a persistent viewer or editor membership to that signed-in account without replacing its session, then opens the exact shared workspace view carried by the invitation while preserving any other workspace already stored on that device. The membership remains until the member leaves, an owner removes it, or the workspace is deleted. The requested return path is accepted only when it belongs to the authorized workspace. Legacy token-path links remain readable and are canonicalized to the fragment form before authentication is checked.
Account deletion is prepared with a read-only review and then confirmed with exact account and membership revisions, literal DELETE, and recent Google proof on the active session. Global-admin and final-owner obligations must be resolved first. Deletion removes direct sign-in identities and safe memberships, revokes sessions and unused links, pseudonymizes retained audit references, scrubs profile and session metadata, and leaves device replicas untouched for separate export, read-only retention, or removal.