Identity & permissions — Design
Problem
Sub-project #7 of the collaborative-annotations initiative adds real identity and permission checks before wiki-browser is exposed on the public internet. The deployment is a Raspberry Pi behind Nginx Proxy Manager; Nginx terminates HTTPS, while wiki-browser owns Google OAuth/OIDC and its own application sessions.
The domain model originally placed identity late in the sequence, but the project now needs it earlier: public read-only document browsing is allowed, yet Topics, messages, proposals, Perspective management, and Agent-triggering actions must be available only to two allowlisted collaborators. This spec defines that boundary without redesigning Topic core, Incorporation, Perspectives, or real-time collaboration.
Goals
- Google OAuth/OIDC login owned by wiki-browser, independent of proxy-provided identity headers.
- A two-person allowlist:
daniel@getorcha.comandmax@getorcha.com. - Public read-only document browsing, with collaborator features hidden from anonymous users.
- Authenticated session management suitable for an internet-facing personal/company tool.
- A small authorization surface that gates current and future collaborative actions without premature role modeling.
- Durable attribution through the existing
userstable and all existing*_byforeign keys. - A development bypass that issues real sessions without the OAuth round-trip, gated behind explicit config and refusing to start over HTTPS.
Non-goals
- Account lifecycle. No signup, invitations, password reset, profile editing, SCIM, or group sync.
- General RBAC. Daniel and Max have identical collaborator permissions in v1. A future role model may build on the policy names here.
- Proxy-owned auth. Nginx Proxy Manager handles TLS and forwarding only; it does not authenticate users or inject identity headers.
- Public sharing controls. All documents remain publicly readable once the site is reachable. Per-path privacy is a separate future feature.
- Detailed #4/#5/#6 workflows. This spec names the permission checks those projects should call, but it does not define their UI or state machines.
- A production auth bypass. Dev mode is config-gated and refuses to start when
public_base_urlis HTTPS.
Approach
Use app-owned OAuth plus app-owned sessions. Google proves the user's email address; wiki-browser decides whether that identity is allowed, creates its own server-side session, and attaches a request principal to protected handlers.
Anonymous users can keep using wiki-browser as a read-only public site. Signing in changes the server-side request principal; the server then renders collaborator affordances and allows protected JSON endpoints. The client never receives hidden collaborator data for anonymous users.
Proxy-owned auth was rejected. It would reduce Go code, but correctness would depend on header-stripping and Nginx configuration. App-owned OAuth keeps local tests representative and keeps authorization close to the collaboration handlers that need it.
Design
Configuration
Authentication is configured explicitly in wiki-browser.yaml. Secrets live outside the repo and are read from files so deployment can mount or rotate them without editing committed config.
yamlauth: public_base_url: "https://wiki.example.com" google_client_id: "123.apps.googleusercontent.com" google_client_secret_file: "/srv/wiki-browser/secrets/google-client-secret" session_secret_file: "/srv/wiki-browser/secrets/session-secret" allowed_emails: - "daniel@getorcha.com" - "max@getorcha.com"
public_base_urlis the canonical HTTPS origin used to build OAuth redirect URIs. The app does not infer this from request headers.google_client_idandgoogle_client_secret_fileidentify the Google OAuth client. The configured Google redirect URI must exactly match{public_base_url}/auth/callback.session_secret_filecontains high-entropy bytes used for signing cookies and CSRF tokens.allowed_emailsis normalized to lowercase at startup. Empty or malformed entries fail validation.
Auth fully replaces the earlier single-operator bootstrap. Once #7 lands, operator is removed from required config, startup no longer inserts a bootstrap operator row, and collaborative mutators receive the authenticated request principal. There is no local single-operator fallback when auth: is absent. If auth config is incomplete, startup fails.
Users and sessions
The existing users table becomes the durable identity table. For this deployment, email is the local user ID:
users.id = lower(email)users.display_name = name claimwhen present, otherwise the email address- Existing columns such as
topics.created_by,topic_messages.author_user_id, andincorporation_attempts.approved_bycontinue to referenceusers.id.
Google's stable sub claim is useful during callback verification, but the local audit trail should remain readable. The allowlist is email-based, so email-as-ID is the right local key unless this deployment later needs domain-wide users or account migration.
Add server-side sessions to the collab DB:
sqlCREATE TABLE auth_sessions ( id_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, csrf_hash TEXT NOT NULL, created_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, revoked_at INTEGER, FOREIGN KEY (user_id) REFERENCES users(id) ); CREATE INDEX auth_sessions_user ON auth_sessions(user_id); CREATE INDEX auth_sessions_expires ON auth_sessions(expires_at);
The browser cookie carries only an opaque random session token. The DB stores a hash of that token, not the raw token. Sessions use a bounded sliding lifetime: for example, 30 days from last activity, with last_seen_at and expires_at updated through the existing single write funnel. Logout sets revoked_at or deletes the row.
Each session also owns one opaque CSRF token. The raw CSRF value is returned only to signed-in same-origin JavaScript via GET /auth/me; SQLite stores csrf_hash. Sliding session expiry does not rotate the CSRF token. Logout, revocation, and session expiry invalidate it together with the session. Every mutating endpoint, including POST /auth/logout, requires the session cookie plus X-CSRF-Token: <raw csrf token>. Missing or invalid CSRF returns 403 Forbidden.
Short-lived OAuth transactions also live in SQLite, separate from durable sessions:
sqlCREATE TABLE auth_oauth_states ( state_hash TEXT PRIMARY KEY, pkce_verifier TEXT NOT NULL, return_path TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, consumed_at INTEGER ); CREATE INDEX auth_oauth_states_expires ON auth_oauth_states(expires_at);
/auth/login creates a high-entropy state, stores only state_hash plus the PKCE verifier and safe local return path, and redirects to Google with the raw state. /auth/callback hashes the received state, finds an unexpired unconsumed row, marks it consumed, then uses the stored verifier for token exchange. Rows expire after 10 minutes and may be deleted opportunistically during login or startup. A process restart does not break pending logins because the transaction state is in SQLite.
Routes
| Route | Auth | Purpose |
|---|---|---|
GET /auth/login | public | Create short-lived OAuth state and PKCE verifier, then redirect to Google. |
GET /auth/callback | public | Verify Google callback, allowlist email, create session, and redirect to the original safe local path. |
POST /auth/logout | session + CSRF | Revoke the current session and return to the same public document view. |
GET /auth/me | optional session | Return anonymous state, or the current principal plus raw CSRF token for same-origin JS bootstrapping. |
Public read routes remain unauthenticated: /, /doc/..., /content/..., /search, /static/..., and /healthz. Collaboration APIs require a valid session even for reads, because Topic and proposal data can contain private discussion about otherwise public Sources.
OAuth callback validation
The callback handler follows the standard server-side OAuth/OIDC code flow:
- Verify the single-use
statevalue and consume the stored PKCE verifier. - Exchange the authorization code for tokens with Google.
- Verify the ID token's signature, issuer, audience, and expiry.
- Require
email_verified == true. - Normalize
emailto lowercase and require membership inallowed_emails. - Upsert
users(id, display_name). - Create an
auth_sessionsrow, including a hashed CSRF token, and set the session cookie. - Redirect to the originally requested local path, or
/if the path is missing or unsafe.
The OAuth scopes are minimal: openid email profile. The Google Workspace hosted-domain claim may be checked as a defense-in-depth hint if present, but the allowlist remains the authority; never accept a domain claim as a replacement for exact email matching.
Development mode
Running the full Google OAuth dance against a local http://localhost:8080 dev server adds friction without exercising any real security property: the allowlist gate that decides authorization runs after Google's redirect, and locally there is no domain or session-store difference between an OAuth-issued session and a hand-issued one. Dev mode short-circuits the dance with a one-click "Continue as <email>" picker drawn from the same allowed_emails list, producing a real session through the same code path OAuth uses.
Dev mode is gated by an explicit auth.dev_mode: true in the YAML and is mutually exclusive with HTTPS:
yamlauth: dev_mode: true public_base_url: "http://localhost:8080" session_secret_file: "./secrets/session-secret" allowed_emails: - "daniel@getorcha.com" - "max@getorcha.com" # google_client_id / google_client_secret_file: optional when dev_mode is true
google_client_idandgoogle_client_secret_filebecome optional.public_base_url,session_secret_file, andallowed_emailsremain required so sessions still sign, redirects still target a known origin, and the picker has identities to render.- Startup fails when
dev_mode: trueANDpublic_base_urluseshttps://. The Pi's config is always HTTPS, so a straydev_mode: truethere cannot start the server. - Startup logs a prominent
DEV MODE ENABLEDwarning so the mode is impossible to miss in logs.
The anonymous UI is identical to production: the chrome shows a single "Sign in" button pointing at /auth/login. The server branches on dev_mode behind that one URL:
- Production:
GET /auth/logincreates OAuth state and redirects to Google as currently specified. - Development:
GET /auth/loginrenders a minimal page listing one "Continue as <email>" button per entry inallowed_emails. Each button is an anchor toGET /auth/dev/login?as=<email>&return=<path>, which validates the email against the allowlist, derives a display name from the email's local part (capitalized) for first-time users, and issues a real session via the sameIssueSessionhelper used by the OAuth callback. - Both modes:
GET /auth/callbackexists, but in dev mode it returns 404 since Google never redirects to it.
The resulting session is indistinguishable from an OAuth-issued one: same wb_session + wb_csrf cookie pair, same auth_sessions row, same sliding 30-day expiry, same CSRF behavior, same logout flow. Middleware, permission helpers, and protected handlers cannot tell which login path produced a session. Anonymous chrome renders exactly the same markup in dev and prod; the only difference is what the server returns from /auth/login.
Dev mode trusts client-supplied identity. A misconfigured Pi with dev_mode: true would let anyone pick "I'm Daniel" and gain collaborator access. The HTTPS-mutually-exclusive startup check is the load-bearing guard; allowed_emails in dev mode is a UI list, not a security boundary.
Authorization model
There is one v1 role: authenticated allowlisted user = collaborator. Daniel and Max have identical permissions.
| Policy | Required principal | Owned by |
|---|---|---|
document:read | anonymous allowed | existing wiki-browser |
topic:create | collaborator | #2 |
topic:message | collaborator | #2 |
topic:discard | collaborator | #4 |
proposal:create | collaborator | #4 |
proposal:approve | collaborator | #4 |
perspective:manage | collaborator | #5 / #8 |
agent:invoke | collaborator | #3 / #4 / #5 |
The implementation should expose a small server helper: public handlers can read an optional principal; protected handlers call something equivalent to RequireCollaborator. Current and future mutators should take the request principal's UserID, not a process-wide OperatorUserID.
UI contract
Anonymous users see wiki-browser almost exactly as it exists today. The only new visible control is a sign-in affordance in the chrome.
- No Topics sidebar for anonymous users.
- No annotation highlights, selection composer, Resolve controls, Perspective switcher, or persona editor for anonymous users.
- Anonymous pages must not fetch collaborator-only data and hide it client-side; the server omits collaborative bootstrapping altogether.
- After login, the same document view can render whichever collaborative features have landed.
- Logout returns the user to the same public document view with collaborator features removed.
Cache policy
HTML can vary by session because the chrome, content iframe, and collaboration bootstrap differ for anonymous and authenticated users. Use conservative headers so collaborator affordances or bootstrapping data cannot be cached into a public response.
/,/doc/..., and/content/...returnCache-Control: no-storeandVary: Cookie. This applies to signed-in and signed-out HTML responses.- Protected JSON routes, including
/auth/mewhen it returns a principal or CSRF token, returnCache-Control: no-store. /static/...may remain cacheable because it is session-independent./searchremains public. If future signed-in search results include collaborator metadata, it must move toCache-Control: no-storeandVary: Cookieat that point.
Documents are public read-only, but Topics, messages, proposals, pending diffs, Perspective definitions, and Agent job metadata are collaborator-only. Public document viewing is not permission to read collaboration state.
Error handling
- OAuth denied or cancelled: return to the public page with a small sign-in failure state.
- Non-allowlisted Google account: show "This Google account is not allowed" and do not create a user or session.
- Unverified email: reject the callback and do not create a session.
- Bad or missing session on a protected API: return
401 UnauthorizedJSON, not a Google redirect. - Authenticated but not permitted: return
403 Forbidden. This is rare in v1 because both allowed users are collaborators. - OAuth state mismatch: reject, clear pre-auth state, and require a fresh login attempt.
Security controls
- Session cookie attributes:
Secure,HttpOnly,SameSite=Lax, and a narrow path of/. - Session tokens are high-entropy random values. Store only a keyed hash or SHA-256 hash of the token in SQLite.
- OAuth state and PKCE verifier are single-use and short-lived.
- Redirect-after-login accepts only local absolute paths such as
/doc/...; full URLs and protocol-relative values fall back to/. - Use
auth.public_base_urlfor OAuth URLs instead of trustingX-Forwarded-ProtoorX-Forwarded-Host. - Mutating JSON endpoints require a valid session cookie plus a per-session CSRF token supplied in
X-CSRF-Token. - Read-only public routes must not set collaborator cache variants that can leak across users. Session-varying HTML and protected JSON use the cache policy above.
Testing
- Config validation: required auth fields, valid HTTPS
public_base_url, readable secret files, non-empty allowlist, lowercase normalization. - Session store: create, lookup, sliding expiry, revoke/logout, expired-session rejection, and verification that raw tokens are never stored.
- CSRF:
/auth/mereturns a raw token only for a valid session; mutating endpoints reject missing, wrong, expired, or revoked-session CSRF tokens. - OAuth transaction store: login stores state + PKCE in SQLite; callback consumes once; expired, consumed, unknown, and restart-surviving states behave deterministically.
- Middleware: public routes pass without a principal; protected API routes return
401without a session; protected handlers receivePrincipal{UserID: email}with a valid session. - OAuth callback with fake verifier: allowed verified email succeeds; non-allowlisted email fails; unverified email fails; bad state fails; unsafe return URL falls back to
/. - Cache headers: session-varying HTML returns
no-storeandVary: Cookie; protected JSON returnsno-store; static assets remain cacheable. - Config migration: auth config is required,
operatoris no longer required, and no bootstrap operator row is inserted. - Dev mode: config with
dev_mode: true+ HTTPSpublic_base_urlfails to load; OAuth fields become optional under dev_mode;/auth/loginin dev mode renders one picker entry per allowed email;/auth/dev/login?as=<email>issues the same session/CSRF cookie pair as the OAuth callback for an allowed email and 403s for an unknown one;/auth/dev/loginand/auth/callback404 outside their respective modes. - Browser smoke: signed-out document view looks like current wiki-browser plus Sign in; signed-out browser does not request Topic APIs; signed-in browser sees collaborator bootstrap data; logout with CSRF returns to public view.
References
- Domain model — vocabulary, invariants, sub-project decomposition.
- Decisions & parking lot — cross-cutting decisions from this and related sub-projects.
- Document model & persistence — existing
userstable and attribution columns that #7 turns into real authenticated principals. - Topic core — current protected API surface for Topic creation, listing, and messages.
cmd/wiki-browser/main.go— current config and dependency wiring.internal/server/server.go— router and future middleware attachment point.