Identity & Permissions Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the single-operator bootstrap with Google OAuth/OIDC login, server-side sessions, CSRF protection, public read-only wiki routes, and a collaborator authorization substrate for current and future collaborative APIs.

Architecture: internal/config owns required auth config and removes operator; internal/collab owns durable users plus session/OAuth-state storage in SQLite; internal/auth owns tokens, principal context, OAuth verifier abstraction, the shared IssueSession helper, OAuth handlers, and the dev-mode picker/handler. internal/server wires auth middleware, cache headers, sign-in/logout chrome, protects the merged Topic Core routes, conditionally registers the dev-mode submission route, and exposes the permission helper future Incorporation/Perspective routes must call.

Tech Stack: Go 1.26, net/http, database/sql with modernc.org/sqlite, crypto/rand, crypto/sha256, encoding/base64, golang.org/x/oauth2, github.com/coreos/go-oidc/v3/oidc, existing html/template and vanilla JavaScript.

Reference spec: docs/superpowers/specs/2026-05-11-identity-permissions-design.html. Also check docs/superpowers/specs/2026-05-10-collaborative-annotations-decisions.md for cross-subproject decisions.


Scope Check

This plan implements sub-project #7 only. Topic Core has now been merged, so this plan does not create Topic behavior, but it does secure the existing Topic APIs and hides the existing Topic UI for anonymous readers. Incorporation, Perspectives, and polished collaborative UI remain out of scope. Public wiki reading stays public; collaborator data and collaborator actions require a signed-in allowlisted Google account.


File Structure

internal/config/
├── config.go                    # MODIFIED: replace Operator with required Auth config
├── config_test.go               # MODIFIED: auth validation/default tests
└── testdata/
    ├── valid.yaml               # MODIFIED: auth block, no operator
    ├── minimal.yaml             # MODIFIED: auth block, no operator
    ├── missing-root.yaml        # MODIFIED: auth block so root remains the failure
    └── bad-root.yaml            # MODIFIED: auth block so bad root remains the failure

internal/collab/
├── collab.go                    # MODIFIED: remove operator bootstrap from Open
├── collab_test.go               # MODIFIED: no operator required; explicit user upsert tests
├── migrations/
│   └── 002_auth.sql             # NEW: auth_sessions + auth_oauth_states
├── migrate_test.go              # MODIFIED: assert auth tables exist
├── auth_store.go                # NEW: users, sessions, oauth-state read/write APIs
└── auth_store_test.go           # NEW: session/state/user storage tests

internal/auth/
├── token.go                     # NEW: random token, hashes, safe return-path helper
├── token_test.go                # NEW
├── principal.go                 # NEW: Principal, context helpers, permission helpers
├── principal_test.go            # NEW
├── oauth.go                     # NEW: OAuthService, verifier interfaces, login/callback logic
├── oauth_test.go                # NEW
├── middleware.go                # NEW: session loading, RequireCollaborator, CSRF guard
├── middleware_test.go           # NEW
├── handlers.go                  # NEW: /auth/login, /auth/callback, /auth/me, /auth/logout
└── handlers_test.go             # NEW

internal/server/
├── server.go                    # MODIFIED: Deps gains Collab + Auth; register auth routes
├── handler_doc.go               # MODIFIED: shell data includes auth state; no-store/Vary headers
├── handler_content.go           # MODIFIED: no-store/Vary on content HTML/raw; no-store where needed
├── embed.go                     # MODIFIED: ShellData adds auth fields
├── topics.go                    # MODIFIED: require auth/CSRF; use principal attribution
├── topics_test.go               # MODIFIED: authenticated topic tests and anonymous/CSRF denials
├── templates/shell.html         # MODIFIED: Sign in / user / logout controls
├── templates/content_md.html     # MODIFIED: expose collaborator hooks only when authenticated
├── templates_test.go            # MODIFIED: auth chrome assertions
├── static/chrome.js             # MODIFIED: load /auth/me, expose CSRF, logout form/header
├── static/content.js            # MODIFIED: disable selection composer/topic focus when anonymous
├── static/chrome.css            # MODIFIED: topbar auth control styling
└── auth_integration_test.go     # NEW: router-level auth/cache behavior tests

cmd/wiki-browser/main.go         # MODIFIED: pass auth config and collab store to server
wiki-browser.example.yaml        # MODIFIED: auth block; remove operator block
go.mod / go.sum                  # MODIFIED: add oauth2 and go-oidc dependencies

Task 1: Config — Replace Operator With Required Auth Config

Files:

In internal/config/testdata/valid.yaml, replace the existing operator: block with:

auth:
  public_base_url: "https://wiki.example.com"
  google_client_id: "client-id.apps.googleusercontent.com"
  google_client_secret_file: "testdata/google-client-secret"
  session_secret_file: "testdata/session-secret"
  allowed_emails:
    - "daniel@getorcha.com"
    - "max@getorcha.com"

In internal/config/testdata/minimal.yaml, append the same auth: block.

In internal/config/testdata/missing-root.yaml and internal/config/testdata/bad-root.yaml, append the same auth: block so those tests keep failing for the root-specific reason.

Create these two fixture secret files:

internal/config/testdata/google-client-secret
test-google-client-secret
internal/config/testdata/session-secret
0123456789abcdef0123456789abcdef

In internal/config/config_test.go, update TestLoad_valid's expected struct:

	want := &config.Config{
		Listen:     ":8080",
		Title:      "Orcha wiki",
		Root:       "/tmp",
		Extensions: []string{".md", ".html"},
		IndexDB:    "./wiki-browser-index.db",
		CollabDB:   "./wiki-browser-collab.db",
		Exclude:    []string{"www/**", "marketing/**"},
		Auth: config.Auth{
			PublicBaseURL:          "https://wiki.example.com",
			GoogleClientID:         "client-id.apps.googleusercontent.com",
			GoogleClientSecretFile: "testdata/google-client-secret",
			SessionSecretFile:      "testdata/session-secret",
			AllowedEmails:          []string{"daniel@getorcha.com", "max@getorcha.com"},
		},
	}

Delete TestLoad_operatorRequired and replace it with:

func TestLoad_authRequired(t *testing.T) {
	tmp := t.TempDir()
	path := filepath.Join(tmp, "no-auth.yaml")
	body := fmt.Sprintf("root: %q\n", tmp)
	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}
	_, err := config.Load(path)
	if err == nil {
		t.Fatal("expected error when auth block is missing, got nil")
	}
	if !strings.Contains(err.Error(), "auth.public_base_url") {
		t.Errorf("error = %q, want auth.public_base_url", err.Error())
	}
}

func TestLoad_authValidation(t *testing.T) {
	tmp := t.TempDir()
	secret := filepath.Join(tmp, "secret")
	if err := os.WriteFile(secret, []byte("0123456789abcdef0123456789abcdef"), 0o600); err != nil {
		t.Fatal(err)
	}
	cases := []struct {
		name string
		body string
		want string
	}{
		{
			name: "http public base url",
			body: fmt.Sprintf("root: %q\nauth:\n  public_base_url: \"http://wiki.example.com\"\n  google_client_id: \"client\"\n  google_client_secret_file: %q\n  session_secret_file: %q\n  allowed_emails: [\"daniel@getorcha.com\"]\n", tmp, secret, secret),
			want: "https",
		},
		{
			name: "missing client id",
			body: fmt.Sprintf("root: %q\nauth:\n  public_base_url: \"https://wiki.example.com\"\n  google_client_secret_file: %q\n  session_secret_file: %q\n  allowed_emails: [\"daniel@getorcha.com\"]\n", tmp, secret, secret),
			want: "google_client_id",
		},
		{
			name: "bad email",
			body: fmt.Sprintf("root: %q\nauth:\n  public_base_url: \"https://wiki.example.com\"\n  google_client_id: \"client\"\n  google_client_secret_file: %q\n  session_secret_file: %q\n  allowed_emails: [\"not-an-email\"]\n", tmp, secret, secret),
			want: "allowed_emails",
		},
		{
			name: "public_base_url with path",
			body: fmt.Sprintf("root: %q\nauth:\n  public_base_url: \"https://wiki.example.com/wiki\"\n  google_client_id: \"client\"\n  google_client_secret_file: %q\n  session_secret_file: %q\n  allowed_emails: [\"daniel@getorcha.com\"]\n", tmp, secret, secret),
			want: "must not include a path",
		},
		{
			name: "dev_mode with https",
			body: fmt.Sprintf("root: %q\nauth:\n  dev_mode: true\n  public_base_url: \"https://wiki.example.com\"\n  session_secret_file: %q\n  allowed_emails: [\"daniel@getorcha.com\"]\n", tmp, secret),
			want: "auth.dev_mode must not be enabled",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			path := filepath.Join(tmp, tc.name+".yaml")
			if err := os.WriteFile(path, []byte(tc.body), 0o644); err != nil {
				t.Fatal(err)
			}
			_, err := config.Load(path)
			if err == nil {
				t.Fatal("expected error, got nil")
			}
			if !strings.Contains(err.Error(), tc.want) {
				t.Errorf("error = %q, want substring %q", err.Error(), tc.want)
			}
		})
	}
}

func TestLoad_devModeRelaxesOAuthFields(t *testing.T) {
	tmp := t.TempDir()
	secret := filepath.Join(tmp, "secret")
	if err := os.WriteFile(secret, []byte("0123456789abcdef0123456789abcdef"), 0o600); err != nil {
		t.Fatal(err)
	}
	path := filepath.Join(tmp, "dev.yaml")
	body := fmt.Sprintf("root: %q\nauth:\n  dev_mode: true\n  public_base_url: \"http://localhost:8080\"\n  session_secret_file: %q\n  allowed_emails:\n    - \"daniel@getorcha.com\"\n    - \"max@getorcha.com\"\n", tmp, secret)
	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}
	cfg, err := config.Load(path)
	if err != nil {
		t.Fatalf("dev_mode + http should load, got %v", err)
	}
	if !cfg.Auth.DevMode || cfg.Auth.PublicBaseURL != "http://localhost:8080" {
		t.Fatalf("Auth = %#v", cfg.Auth)
	}
	if cfg.Auth.GoogleClientID != "" || cfg.Auth.GoogleClientSecretFile != "" {
		t.Fatalf("dev_mode should not require OAuth fields, got %#v", cfg.Auth)
	}
}

func TestLoad_publicBaseURLNormalization(t *testing.T) {
	tmp := t.TempDir()
	secret := filepath.Join(tmp, "secret")
	if err := os.WriteFile(secret, []byte("0123456789abcdef0123456789abcdef"), 0o600); err != nil {
		t.Fatal(err)
	}
	cases := map[string]string{
		"https://wiki.example.com":  "https://wiki.example.com",
		"https://wiki.example.com/": "https://wiki.example.com",
	}
	for input, want := range cases {
		t.Run(input, func(t *testing.T) {
			path := filepath.Join(tmp, "ok.yaml")
			body := fmt.Sprintf("root: %q\nauth:\n  public_base_url: %q\n  google_client_id: \"client\"\n  google_client_secret_file: %q\n  session_secret_file: %q\n  allowed_emails: [\"daniel@getorcha.com\"]\n", tmp, input, secret, secret)
			if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
				t.Fatal(err)
			}
			cfg, err := config.Load(path)
			if err != nil {
				t.Fatal(err)
			}
			if cfg.Auth.PublicBaseURL != want {
				t.Errorf("PublicBaseURL = %q, want %q", cfg.Auth.PublicBaseURL, want)
			}
		})
	}
}

Run: go test ./internal/config/... -v

Expected: FAIL because config.Config still has Operator, not Auth.

In internal/config/config.go, replace the Operator field/type with:

	Auth     Auth     `yaml:"auth"`

Add the type:

// Auth configures Google OAuth/OIDC and app-owned sessions. DevMode replaces
// the OAuth round-trip with an on-page user picker for local development;
// see Validate() for the HTTPS-mutually-exclusive guard.
type Auth struct {
	PublicBaseURL          string   `yaml:"public_base_url"`
	DevMode                bool     `yaml:"dev_mode"`
	GoogleClientID         string   `yaml:"google_client_id"`
	GoogleClientSecretFile string   `yaml:"google_client_secret_file"`
	SessionSecretFile      string   `yaml:"session_secret_file"`
	AllowedEmails          []string `yaml:"allowed_emails"`
}

Add imports:

	"net/mail"
	"net/url"
	"strings"

Replace the operator validation block with:

	if c.Auth.PublicBaseURL == "" {
		return fmt.Errorf("auth.public_base_url is required")
	}
	u, err := url.Parse(c.Auth.PublicBaseURL)
	// Dev mode allows http (production requires https); both modes reject
	// schemes other than http/https and empty hosts.
	allowedScheme := u != nil && (u.Scheme == "https" || (c.Auth.DevMode && u.Scheme == "http"))
	if err != nil || !allowedScheme || u.Host == "" {
		return fmt.Errorf("auth.public_base_url must be an https URL (or http when auth.dev_mode is true)")
	}
	// Production guard: dev_mode + https is almost certainly a mis-edit of the
	// Pi config. Refuse to start so the public deployment cannot accidentally
	// run the client-trusting picker.
	if c.Auth.DevMode && u.Scheme == "https" {
		return fmt.Errorf("auth.dev_mode must not be enabled when auth.public_base_url is https")
	}
	// Normalize to scheme://host with no trailing slash and no path. Google's
	// OAuth client requires the registered redirect_uri to match exactly; a
	// trailing slash in the configured value would yield `//auth/callback`
	// after concatenation and break the exchange.
	if u.Path != "" && u.Path != "/" {
		return fmt.Errorf("auth.public_base_url must not include a path; got %q", u.Path)
	}
	if u.RawQuery != "" || u.Fragment != "" {
		return fmt.Errorf("auth.public_base_url must not include query or fragment")
	}
	c.Auth.PublicBaseURL = u.Scheme + "://" + u.Host
	// OAuth client fields are required in production and optional in dev mode
	// (the dev-mode picker bypasses the OAuth round-trip). session_secret_file
	// and allowed_emails remain required in both modes — sessions still need
	// signing, the picker needs identities to render.
	if !c.Auth.DevMode {
		if c.Auth.GoogleClientID == "" {
			return fmt.Errorf("auth.google_client_id is required")
		}
		if c.Auth.GoogleClientSecretFile == "" {
			return fmt.Errorf("auth.google_client_secret_file is required")
		}
		if _, err := os.Stat(c.Auth.GoogleClientSecretFile); err != nil {
			return fmt.Errorf("auth.google_client_secret_file %s: %w", c.Auth.GoogleClientSecretFile, err)
		}
	}
	if c.Auth.SessionSecretFile == "" {
		return fmt.Errorf("auth.session_secret_file is required")
	}
	if info, err := os.Stat(c.Auth.SessionSecretFile); err != nil {
		return fmt.Errorf("auth.session_secret_file %s: %w", c.Auth.SessionSecretFile, err)
	} else if info.IsDir() {
		return fmt.Errorf("auth.session_secret_file %s: is a directory", c.Auth.SessionSecretFile)
	}
	if len(c.Auth.AllowedEmails) == 0 {
		return fmt.Errorf("auth.allowed_emails is required")
	}
	for i, email := range c.Auth.AllowedEmails {
		normalized := strings.ToLower(strings.TrimSpace(email))
		addr, err := mail.ParseAddress(normalized)
		if err != nil || addr.Address != normalized || !strings.Contains(normalized, "@") {
			return fmt.Errorf("auth.allowed_emails[%d] is invalid", i)
		}
		c.Auth.AllowedEmails[i] = normalized
	}

In wiki-browser.example.yaml, change the first line to:

# wiki-browser config — copy to wiki-browser.yaml and edit root/auth secrets.

Delete the operator: block and replace it with:

auth:
  # public_base_url must be https in production and contain no path, query, or
  # fragment. No trailing slash — config-load normalizes it away anyway, but
  # Google's OAuth client matches the registered redirect URI exactly, so it
  # pays to write the canonical form here.
  public_base_url: "https://wiki.example.com"
  google_client_id: "replace-me.apps.googleusercontent.com"
  # Secrets live on disk so they can be mounted/rotated without editing this
  # file. Rotation requires a process restart.
  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"

  # Local development bypass. When dev_mode is true, /auth/login renders a
  # one-click picker over allowed_emails instead of redirecting to Google,
  # and google_client_id / google_client_secret_file become optional.
  # Refuses to start when public_base_url is https. NEVER enable on the Pi.
  # dev_mode: true

Run: go test ./internal/config/... -v

Expected: PASS.

git add internal/config/config.go internal/config/config_test.go internal/config/testdata wiki-browser.example.yaml
git commit -m "wiki-browser: config — require auth block"

Task 2: Collab Schema — Auth Tables and Remove Bootstrap Operator

Files:

In internal/collab/migrate_test.go, update the table list in TestMigrate_realSchema_smoke to include auth tables:

	for _, name := range []string{
		"users", "topics", "topic_messages",
		"incorporation_proposals", "incorporation_attempts",
		"perspective_defs", "perspectives",
		"auth_sessions", "auth_oauth_states",
	} {

Append:

func TestMigrate_realSchema_authSessionFK(t *testing.T) {
	db := openMemDB(t)
	if err := Migrate(db, MigrationsFS); err != nil {
		t.Fatal(err)
	}
	_, err := db.Exec(
		`INSERT INTO auth_sessions(id_hash, user_id, csrf_hash, created_at, last_seen_at, expires_at)
		 VALUES('s', 'missing@example.com', 'c', 1, 1, 2)`,
	)
	if err == nil {
		t.Fatal("expected FK violation for missing session user")
	}
}

The new Config no longer carries OperatorUserID/OperatorDisplayName, so EVERY existing collab.Open(collab.Config{...}) call site in the repo must drop those fields. Sweep all of:

Plus any helper that wraps collab.Open (openMemDB, newTestStore, etc.) — grep for OperatorUserID and OperatorDisplayName and replace every literal call with:

	s, err := collab.Open(collab.Config{Path: path})

Run:

grep -rn "OperatorUserID\|OperatorDisplayName" internal/collab cmd internal/server

Expected after the sweep: zero hits. If any test still references Config.OperatorUserID, compilation fails after Step 5.

Append:

func TestOpen_doesNotBootstrapOperator(t *testing.T) {
	path := filepath.Join(t.TempDir(), "no-operator.db")
	s, err := collab.Open(collab.Config{Path: path})
	if err != nil {
		t.Fatal(err)
	}
	defer s.Close()

	var n int
	if err := s.RawDBForTest().QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n); err != nil {
		t.Fatal(err)
	}
	if n != 0 {
		t.Fatalf("users count = %d, want 0", n)
	}
}

Run: go test ./internal/collab/... -run 'TestOpen|TestMigrate_realSchema' -v

Expected: FAIL because auth tables do not exist and Open still requires operator config.

Create internal/collab/migrations/002_auth.sql:

-- Auth sessions — opaque session cookies, hashed at rest.
CREATE 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);

-- OAuth login transactions — short-lived state + PKCE verifier.
-- Note: no user_id column. The identity is unknown when the row is
-- inserted (the row exists between /auth/login and /auth/callback); we
-- learn the identity only after exchanging the code. If audit forensics
-- ever need to tie a consumed state to an identity, the path is to log at
-- callback time rather than reshape this table.
CREATE 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);

In internal/collab/collab.go, change Config to:

type Config struct {
	Path string // filesystem path to the SQLite DB
}

Change the Open comment to:

// Open opens the collab DB at cfg.Path, applies migrations, and starts
// the write funnel.

Delete the Config.Operator* validation, delete the bootstrapOperator call, and delete the bootstrapOperator function. The top of Open should start:

func Open(cfg Config) (*Store, error) {
	if cfg.Path == "" {
		return nil, fmt.Errorf("collab: Config.Path is required")
	}

	dsn := cfg.Path + "?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=busy_timeout(5000)"

Run: go test ./internal/collab/... -run 'TestOpen|TestMigrate_realSchema' -v

Expected: PASS.

git add internal/collab/collab.go internal/collab/collab_test.go internal/collab/migrate_test.go internal/collab/migrations/002_auth.sql
git commit -m "wiki-browser: collab — add auth tables"

Task 3: Collab Store — Users, Sessions, OAuth State

Files:

Create internal/collab/auth_store_test.go:

package collab_test

import (
	"path/filepath"
	"testing"
	"time"

	"github.com/getorcha/wiki-browser/internal/collab"
)

func openAuthStore(t *testing.T) *collab.Store {
	t.Helper()
	s, err := collab.Open(collab.Config{Path: filepath.Join(t.TempDir(), "auth.db")})
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = s.Close() })
	return s
}

func TestUpsertUser(t *testing.T) {
	s := openAuthStore(t)
	if err := s.UpsertUser(collab.User{ID: "daniel@getorcha.com", DisplayName: "Daniel"}); err != nil {
		t.Fatal(err)
	}
	if err := s.UpsertUser(collab.User{ID: "daniel@getorcha.com", DisplayName: "Daniel B"}); err != nil {
		t.Fatal(err)
	}
	var name string
	err := s.RawDBForTest().QueryRow(`SELECT display_name FROM users WHERE id = ?`, "daniel@getorcha.com").Scan(&name)
	if err != nil {
		t.Fatal(err)
	}
	if name != "Daniel B" {
		t.Fatalf("display_name = %q", name)
	}
}

func TestSessionLifecycle(t *testing.T) {
	s := openAuthStore(t)
	now := time.Unix(100, 0)
	if err := s.UpsertUser(collab.User{ID: "max@getorcha.com", DisplayName: "Max"}); err != nil {
		t.Fatal(err)
	}
	if err := s.CreateSession(collab.Session{
		IDHash: "session-hash", UserID: "max@getorcha.com", CSRFHash: "csrf-hash",
		CreatedAt: now, LastSeenAt: now, ExpiresAt: now.Add(time.Hour),
	}); err != nil {
		t.Fatal(err)
	}
	got, ok, err := s.LookupSession("session-hash", now.Add(time.Minute))
	if err != nil {
		t.Fatal(err)
	}
	if !ok || got.UserID != "max@getorcha.com" || got.CSRFHash != "csrf-hash" {
		t.Fatalf("LookupSession = %#v ok=%v", got, ok)
	}
	if err := s.TouchSession("session-hash", now.Add(2*time.Minute), now.Add(31*24*time.Hour)); err != nil {
		t.Fatal(err)
	}
	if err := s.RevokeSession("session-hash", now.Add(3*time.Minute)); err != nil {
		t.Fatal(err)
	}
	if _, ok, err := s.LookupSession("session-hash", now.Add(4*time.Minute)); err != nil || ok {
		t.Fatalf("revoked LookupSession ok=%v err=%v", ok, err)
	}
}

func TestRotateSessionCSRF(t *testing.T) {
	s := openAuthStore(t)
	now := time.Unix(150, 0)
	if err := s.UpsertUser(collab.User{ID: "daniel@getorcha.com", DisplayName: "Daniel"}); err != nil {
		t.Fatal(err)
	}
	if err := s.CreateSession(collab.Session{
		IDHash: "session-hash", UserID: "daniel@getorcha.com", CSRFHash: "old-csrf",
		CreatedAt: now, LastSeenAt: now, ExpiresAt: now.Add(time.Hour),
	}); err != nil {
		t.Fatal(err)
	}
	if err := s.RotateSessionCSRF("session-hash", "new-csrf"); err != nil {
		t.Fatal(err)
	}
	got, ok, err := s.LookupSession("session-hash", now.Add(time.Minute))
	if err != nil || !ok {
		t.Fatalf("LookupSession ok=%v err=%v", ok, err)
	}
	if got.CSRFHash != "new-csrf" {
		t.Fatalf("CSRFHash = %q, want new-csrf", got.CSRFHash)
	}
}

func TestOAuthStateConsumeOnce(t *testing.T) {
	s := openAuthStore(t)
	now := time.Unix(200, 0)
	state := collab.OAuthState{
		StateHash: "state-hash", PKCEVerifier: "verifier", ReturnPath: "/doc/a.md",
		CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute),
	}
	if err := s.CreateOAuthState(state); err != nil {
		t.Fatal(err)
	}
	got, ok, err := s.ConsumeOAuthState("state-hash", now.Add(time.Minute))
	if err != nil {
		t.Fatal(err)
	}
	if !ok || got.PKCEVerifier != "verifier" || got.ReturnPath != "/doc/a.md" {
		t.Fatalf("ConsumeOAuthState = %#v ok=%v", got, ok)
	}
	if _, ok, err := s.ConsumeOAuthState("state-hash", now.Add(2*time.Minute)); err != nil || ok {
		t.Fatalf("second ConsumeOAuthState ok=%v err=%v", ok, err)
	}
}

func TestCreateUserAndSessionAtomic(t *testing.T) {
	s := openAuthStore(t)
	now := time.Unix(300, 0)
	user := collab.User{ID: "daniel@getorcha.com", DisplayName: "Daniel"}
	session := collab.Session{
		IDHash: "id", UserID: "daniel@getorcha.com", CSRFHash: "csrf",
		CreatedAt: now, LastSeenAt: now, ExpiresAt: now.Add(time.Hour),
	}
	if err := s.CreateUserAndSession(user, session); err != nil {
		t.Fatal(err)
	}
	got, ok, err := s.LookupSession("id", now.Add(time.Minute))
	if err != nil || !ok || got.UserID != "daniel@getorcha.com" {
		t.Fatalf("LookupSession = %#v ok=%v err=%v", got, ok, err)
	}
	// Mismatch must error and roll back: neither user nor session row is left behind.
	bad := openAuthStore(t)
	if err := bad.CreateUserAndSession(
		collab.User{ID: "a@a", DisplayName: "A"},
		collab.Session{IDHash: "id2", UserID: "b@b", CSRFHash: "c", CreatedAt: now, LastSeenAt: now, ExpiresAt: now.Add(time.Hour)},
	); err == nil {
		t.Fatal("expected error on user/session id mismatch")
	}
	var users int
	if err := bad.RawDBForTest().QueryRow(`SELECT count(*) FROM users`).Scan(&users); err != nil {
		t.Fatal(err)
	}
	if users != 0 {
		t.Fatalf("orphan user row after rollback: count=%d", users)
	}
}

// RevokeSessionsNotIn evicts sessions for users no longer in the allowlist.
// Used at startup so removing an email from `auth.allowed_emails` and
// restarting actually kicks that user out.
func TestRevokeSessionsNotIn(t *testing.T) {
	s := openAuthStore(t)
	now := time.Unix(400, 0)
	for _, id := range []string{"daniel@getorcha.com", "max@getorcha.com", "leaver@example.com"} {
		if err := s.UpsertUser(collab.User{ID: id, DisplayName: id}); err != nil {
			t.Fatal(err)
		}
		if err := s.CreateSession(collab.Session{
			IDHash: "id-" + id, UserID: id, CSRFHash: "csrf",
			CreatedAt: now, LastSeenAt: now, ExpiresAt: now.Add(time.Hour),
		}); err != nil {
			t.Fatal(err)
		}
	}
	revoked, err := s.RevokeSessionsNotIn([]string{"daniel@getorcha.com", "max@getorcha.com"}, now.Add(time.Minute))
	if err != nil {
		t.Fatal(err)
	}
	if revoked != 1 {
		t.Fatalf("revoked = %d, want 1", revoked)
	}
	if _, ok, _ := s.LookupSession("id-leaver@example.com", now.Add(2*time.Minute)); ok {
		t.Fatal("removed user's session still resolvable after revocation pass")
	}
	if _, ok, _ := s.LookupSession("id-daniel@getorcha.com", now.Add(2*time.Minute)); !ok {
		t.Fatal("daniel's session was revoked but should have been preserved")
	}
}

Run: go test ./internal/collab/... -run 'TestUpsertUser|TestSessionLifecycle|TestOAuthStateConsumeOnce' -v

Expected: FAIL because the auth store methods do not exist.

Create internal/collab/auth_store.go:

package collab

import (
	"database/sql"
	"fmt"
	"time"
)

type User struct {
	ID          string
	DisplayName string
}

func (s *Store) UpsertUser(u User) error {
	if u.ID == "" || u.DisplayName == "" {
		return fmt.Errorf("collab.UpsertUser: id/display_name required")
	}
	return s.send(func(db *sql.DB) error {
		_, err := db.Exec(
			`INSERT INTO users(id, display_name, created_at)
			 VALUES (?, ?, unixepoch())
			 ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name`,
			u.ID, u.DisplayName,
		)
		return err
	})
}

type Session struct {
	IDHash     string
	UserID     string
	CSRFHash   string
	CreatedAt  time.Time
	LastSeenAt time.Time
	ExpiresAt  time.Time
}

func (s *Store) CreateSession(in Session) error {
	if in.IDHash == "" || in.UserID == "" || in.CSRFHash == "" {
		return fmt.Errorf("collab.CreateSession: id_hash/user_id/csrf_hash required")
	}
	return s.send(func(db *sql.DB) error {
		_, err := db.Exec(
			`INSERT INTO auth_sessions(id_hash, user_id, csrf_hash, created_at, last_seen_at, expires_at)
			 VALUES (?, ?, ?, ?, ?, ?)`,
			in.IDHash, in.UserID, in.CSRFHash,
			in.CreatedAt.Unix(), in.LastSeenAt.Unix(), in.ExpiresAt.Unix(),
		)
		return err
	})
}

func (s *Store) LookupSession(idHash string, now time.Time) (Session, bool, error) {
	var out Session
	var createdAt, lastSeenAt, expiresAt int64
	err := s.db.QueryRow(
		`SELECT id_hash, user_id, csrf_hash, created_at, last_seen_at, expires_at
		 FROM auth_sessions
		 WHERE id_hash = ?
		   AND revoked_at IS NULL
		   AND expires_at > ?`,
		idHash, now.Unix(),
	).Scan(&out.IDHash, &out.UserID, &out.CSRFHash, &createdAt, &lastSeenAt, &expiresAt)
	if err == sql.ErrNoRows {
		return Session{}, false, nil
	}
	if err != nil {
		return Session{}, false, err
	}
	out.CreatedAt = time.Unix(createdAt, 0)
	out.LastSeenAt = time.Unix(lastSeenAt, 0)
	out.ExpiresAt = time.Unix(expiresAt, 0)
	return out, true, nil
}

func (s *Store) TouchSession(idHash string, seenAt, expiresAt time.Time) error {
	if idHash == "" {
		return fmt.Errorf("collab.TouchSession: id_hash required")
	}
	return s.send(func(db *sql.DB) error {
		_, err := db.Exec(
			`UPDATE auth_sessions
			 SET last_seen_at = ?, expires_at = ?
			 WHERE id_hash = ? AND revoked_at IS NULL`,
			seenAt.Unix(), expiresAt.Unix(), idHash,
		)
		return err
	})
}

func (s *Store) RevokeSession(idHash string, revokedAt time.Time) error {
	if idHash == "" {
		return fmt.Errorf("collab.RevokeSession: id_hash required")
	}
	return s.send(func(db *sql.DB) error {
		_, err := db.Exec(
			`UPDATE auth_sessions SET revoked_at = ? WHERE id_hash = ?`,
			revokedAt.Unix(), idHash,
		)
		return err
	})
}

func (s *Store) RotateSessionCSRF(idHash, csrfHash string) error {
	if idHash == "" || csrfHash == "" {
		return fmt.Errorf("collab.RotateSessionCSRF: id_hash/csrf_hash required")
	}
	return s.send(func(db *sql.DB) error {
		_, err := db.Exec(
			`UPDATE auth_sessions SET csrf_hash = ? WHERE id_hash = ? AND revoked_at IS NULL`,
			csrfHash, idHash,
		)
		return err
	})
}

// CreateUserAndSession upserts the user row and creates the session in one
// transaction. Done separately, a concurrent writer could touch the users
// row between the upsert and the session insert; a session-insert failure
// would also leave a half-bootstrapped user behind. Both invariants matter
// because every collaborative action attributes through the FK.
func (s *Store) CreateUserAndSession(u User, in Session) error {
	if u.ID == "" || u.DisplayName == "" {
		return fmt.Errorf("collab.CreateUserAndSession: user id/display_name required")
	}
	if in.IDHash == "" || in.UserID == "" || in.CSRFHash == "" {
		return fmt.Errorf("collab.CreateUserAndSession: session id_hash/user_id/csrf_hash required")
	}
	if u.ID != in.UserID {
		return fmt.Errorf("collab.CreateUserAndSession: user.id %q != session.user_id %q", u.ID, in.UserID)
	}
	return s.sendTx(func(tx *sql.Tx) error {
		if _, err := tx.Exec(
			`INSERT INTO users(id, display_name, created_at)
			 VALUES (?, ?, unixepoch())
			 ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name`,
			u.ID, u.DisplayName,
		); err != nil {
			return err
		}
		_, err := tx.Exec(
			`INSERT INTO auth_sessions(id_hash, user_id, csrf_hash, created_at, last_seen_at, expires_at)
			 VALUES (?, ?, ?, ?, ?, ?)`,
			in.IDHash, in.UserID, in.CSRFHash,
			in.CreatedAt.Unix(), in.LastSeenAt.Unix(), in.ExpiresAt.Unix(),
		)
		return err
	})
}

// RevokeSessionsNotIn revokes every live session whose user_id is not in
// the supplied allowlist. Called once at startup so that removing an entry
// from `auth.allowed_emails` and restarting evicts that user — without it
// a removed user keeps their session up to the full sliding lifetime.
// Returns the count of revoked rows for logging.
func (s *Store) RevokeSessionsNotIn(allowedUserIDs []string, revokedAt time.Time) (int64, error) {
	allowed := make(map[string]struct{}, len(allowedUserIDs))
	for _, id := range allowedUserIDs {
		allowed[id] = struct{}{}
	}
	var revoked int64
	err := s.sendTx(func(tx *sql.Tx) error {
		rows, err := tx.Query(`SELECT id_hash, user_id FROM auth_sessions WHERE revoked_at IS NULL`)
		if err != nil {
			return err
		}
		var toRevoke []string
		for rows.Next() {
			var idHash, userID string
			if err := rows.Scan(&idHash, &userID); err != nil {
				rows.Close()
				return err
			}
			if _, ok := allowed[userID]; !ok {
				toRevoke = append(toRevoke, idHash)
			}
		}
		if err := rows.Close(); err != nil {
			return err
		}
		stamp := revokedAt.Unix()
		for _, idHash := range toRevoke {
			if _, err := tx.Exec(`UPDATE auth_sessions SET revoked_at = ? WHERE id_hash = ?`, stamp, idHash); err != nil {
				return err
			}
			revoked++
		}
		return nil
	})
	return revoked, err
}

type OAuthState struct {
	StateHash    string
	PKCEVerifier string
	ReturnPath   string
	CreatedAt    time.Time
	ExpiresAt    time.Time
}

func (s *Store) CreateOAuthState(in OAuthState) error {
	if in.StateHash == "" || in.PKCEVerifier == "" || in.ReturnPath == "" {
		return fmt.Errorf("collab.CreateOAuthState: state_hash/pkce_verifier/return_path required")
	}
	return s.send(func(db *sql.DB) error {
		_, err := db.Exec(
			`INSERT INTO auth_oauth_states(state_hash, pkce_verifier, return_path, created_at, expires_at)
			 VALUES (?, ?, ?, ?, ?)`,
			in.StateHash, in.PKCEVerifier, in.ReturnPath, in.CreatedAt.Unix(), in.ExpiresAt.Unix(),
		)
		return err
	})
}

func (s *Store) ConsumeOAuthState(stateHash string, now time.Time) (OAuthState, bool, error) {
	var out OAuthState
	var createdAt, expiresAt int64
	err := s.sendTx(func(tx *sql.Tx) error {
		err := tx.QueryRow(
			`SELECT state_hash, pkce_verifier, return_path, created_at, expires_at
			 FROM auth_oauth_states
			 WHERE state_hash = ?
			   AND consumed_at IS NULL
			   AND expires_at > ?`,
			stateHash, now.Unix(),
		).Scan(&out.StateHash, &out.PKCEVerifier, &out.ReturnPath, &createdAt, &expiresAt)
		if err != nil {
			return err
		}
		_, err = tx.Exec(
			`UPDATE auth_oauth_states SET consumed_at = ? WHERE state_hash = ?`,
			now.Unix(), stateHash,
		)
		return err
	})
	if err == sql.ErrNoRows {
		return OAuthState{}, false, nil
	}
	if err != nil {
		return OAuthState{}, false, err
	}
	out.CreatedAt = time.Unix(createdAt, 0)
	out.ExpiresAt = time.Unix(expiresAt, 0)
	return out, true, nil
}

func (s *Store) DeleteExpiredOAuthStates(now time.Time) error {
	return s.send(func(db *sql.DB) error {
		_, err := db.Exec(`DELETE FROM auth_oauth_states WHERE expires_at <= ?`, now.Unix())
		return err
	})
}

func (s *Store) sendTx(apply func(*sql.Tx) error) error {
	return s.send(func(db *sql.DB) error {
		tx, err := db.Begin()
		if err != nil {
			return err
		}
		if err := apply(tx); err != nil {
			_ = tx.Rollback()
			return err
		}
		return tx.Commit()
	})
}

Run: go test ./internal/collab/... -run 'TestUpsertUser|TestSessionLifecycle|TestRotateSessionCSRF|TestOAuthStateConsumeOnce' -v

Expected: PASS.

Run: go test ./internal/collab/... -v

Expected: PASS.

git add internal/collab/auth_store.go internal/collab/auth_store_test.go
git commit -m "wiki-browser: collab — session and oauth state store"

Task 4: Auth Primitives — Tokens, Principal, Permissions

Files:

Create internal/auth/token_test.go:

package auth_test

import (
	"strings"
	"testing"

	"github.com/getorcha/wiki-browser/internal/auth"
)

func TestRandomToken(t *testing.T) {
	a, err := auth.RandomToken(32)
	if err != nil {
		t.Fatal(err)
	}
	b, err := auth.RandomToken(32)
	if err != nil {
		t.Fatal(err)
	}
	if a == b {
		t.Fatal("two random tokens were identical")
	}
	if strings.ContainsAny(a, "+/=") {
		t.Fatalf("token should be raw-url base64 without padding: %q", a)
	}
}

func TestTokenHash(t *testing.T) {
	a := auth.TokenHash("secret-token")
	b := auth.TokenHash("secret-token")
	c := auth.TokenHash("other-token")
	if a != b {
		t.Fatal("same token produced different hashes")
	}
	if a == c {
		t.Fatal("different tokens produced same hash")
	}
	if auth.TokenHash("") == "" {
		t.Fatal("empty token hash should still be deterministic hex")
	}
}

func TestSafeReturnPath(t *testing.T) {
	for _, in := range []string{"/", "/doc/a.md", "/content/docs/x.html"} {
		if got := auth.SafeReturnPath(in); got != in {
			t.Fatalf("SafeReturnPath(%q) = %q", in, got)
		}
	}
	for _, in := range []string{"", "https://evil.test/", "//evil.test/x", "doc/no-leading-slash"} {
		if got := auth.SafeReturnPath(in); got != "/" {
			t.Fatalf("SafeReturnPath(%q) = %q, want /", in, got)
		}
	}
}

Create internal/auth/principal_test.go:

package auth_test

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/getorcha/wiki-browser/internal/auth"
)

func TestPrincipalContext(t *testing.T) {
	r := httptest.NewRequest("GET", "/", nil)
	if _, ok := auth.PrincipalFrom(r.Context()); ok {
		t.Fatal("unexpected principal")
	}
	p := auth.Principal{UserID: "daniel@getorcha.com", DisplayName: "Daniel"}
	ctx := auth.WithPrincipal(r.Context(), p)
	got, ok := auth.PrincipalFrom(ctx)
	if !ok || got.UserID != p.UserID {
		t.Fatalf("PrincipalFrom = %#v ok=%v", got, ok)
	}
}

func TestRequireCollaborator(t *testing.T) {
	called := false
	next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		called = true
	})
	rr := httptest.NewRecorder()
	auth.RequireCollaborator(next).ServeHTTP(rr, httptest.NewRequest("GET", "/x", nil))
	if rr.Code != http.StatusUnauthorized {
		t.Fatalf("status = %d, want 401", rr.Code)
	}
	if called {
		t.Fatal("next handler was called without principal")
	}
}

Run: go test ./internal/auth/... -run 'TestRandomToken|TestTokenHash|TestSafeReturnPath|TestPrincipal' -v

Expected: FAIL because internal/auth does not exist.

Create internal/auth/token.go:

package auth

import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"net/url"
	"strings"
)

func RandomToken(n int) (string, error) {
	b := make([]byte, n)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

func TokenHash(token string) string {
	sum := sha256.Sum256([]byte(token))
	return hex.EncodeToString(sum[:])
}

func SafeReturnPath(in string) string {
	if in == "" || !strings.HasPrefix(in, "/") || strings.HasPrefix(in, "//") {
		return "/"
	}
	u, err := url.Parse(in)
	if err != nil || u.IsAbs() || u.Host != "" {
		return "/"
	}
	return in
}

Create internal/auth/principal.go:

package auth

import (
	"context"
	"encoding/json"
	"net/http"
)

type Principal struct {
	UserID      string `json:"user_id"`
	DisplayName string `json:"display_name"`
}

type principalKey struct{}

func WithPrincipal(ctx context.Context, p Principal) context.Context {
	return context.WithValue(ctx, principalKey{}, p)
}

func PrincipalFrom(ctx context.Context) (Principal, bool) {
	p, ok := ctx.Value(principalKey{}).(Principal)
	return p, ok
}

func RequireCollaborator(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if _, ok := PrincipalFrom(r.Context()); !ok {
			writeAuthJSON(w, http.StatusUnauthorized, "unauthorized")
			return
		}
		next.ServeHTTP(w, r)
	})
}

func writeAuthJSON(w http.ResponseWriter, status int, code string) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(map[string]string{"error": code})
}

Run: go test ./internal/auth/... -run 'TestRandomToken|TestTokenHash|TestSafeReturnPath|TestPrincipal|TestRequireCollaborator' -v

Expected: PASS.

git add internal/auth/token.go internal/auth/token_test.go internal/auth/principal.go internal/auth/principal_test.go
git commit -m "wiki-browser: auth — tokens and principals"

Task 5: OAuth Service — Login and Callback Logic

Files:

Run:

go get golang.org/x/oauth2 github.com/coreos/go-oidc/v3/oidc

Expected: go.mod gains direct requirements for golang.org/x/oauth2 and github.com/coreos/go-oidc/v3/oidc.

Create internal/auth/oauth_test.go:

package auth_test

import (
	"context"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"errors"
	"net/url"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/getorcha/wiki-browser/internal/auth"
	"github.com/getorcha/wiki-browser/internal/collab"
)

type fakeVerifier struct {
	claims  auth.IDTokenClaims
	err     error
	gotPKCE string
}

func (f *fakeVerifier) ExchangeAndVerify(_ context.Context, _ string, pkceVerifier string) (auth.IDTokenClaims, error) {
	f.gotPKCE = pkceVerifier
	return f.claims, f.err
}

func TestOAuthStartStoresState(t *testing.T) {
	s := openAuthCollab(t)
	svc := auth.NewOAuthService(auth.OAuthConfig{
		PublicBaseURL:  "https://wiki.example.com",
		ClientID:       "client",
		AllowedEmails:  []string{"daniel@getorcha.com"},
		Verifier:       &fakeVerifier{},
		Store:          s,
		Now:            func() time.Time { return time.Unix(100, 0) },
		SessionLifetime: 30 * 24 * time.Hour,
	})
	redirect, err := svc.StartLogin(context.Background(), "/doc/a.md")
	if err != nil {
		t.Fatal(err)
	}
	u, err := url.Parse(redirect)
	if err != nil {
		t.Fatal(err)
	}
	state := u.Query().Get("state")
	if state == "" {
		t.Fatalf("redirect missing state: %s", redirect)
	}
	if got := u.Query().Get("code_challenge_method"); got != "S256" {
		t.Fatalf("code_challenge_method = %q, want S256", got)
	}
	challenge := u.Query().Get("code_challenge")
	if len(challenge) != 43 {
		t.Fatalf("code_challenge = %q (len %d), want 43-char base64url", challenge, len(challenge))
	}
	if _, err := base64.RawURLEncoding.DecodeString(challenge); err != nil {
		t.Fatalf("code_challenge not raw base64url: %v", err)
	}
	got, ok, err := s.ConsumeOAuthState(auth.TokenHash(state), time.Unix(101, 0))
	if err != nil || !ok {
		t.Fatalf("state was not stored: ok=%v err=%v", ok, err)
	}
	sum := sha256.Sum256([]byte(got.PKCEVerifier))
	if base64.RawURLEncoding.EncodeToString(sum[:]) != challenge {
		t.Fatal("code_challenge does not match SHA-256 of stored verifier")
	}
}

func TestOAuthCallbackAllowlist(t *testing.T) {
	s := openAuthCollab(t)
	state := "raw-state"
	now := time.Unix(100, 0)
	if err := s.CreateOAuthState(collab.OAuthState{
		StateHash: auth.TokenHash(state), PKCEVerifier: "verifier", ReturnPath: "/doc/a.md",
		CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute),
	}); err != nil {
		t.Fatal(err)
	}
	verifier := &fakeVerifier{claims: auth.IDTokenClaims{
		Email: "daniel@getorcha.com", EmailVerified: true, Name: "Daniel",
	}}
	svc := auth.NewOAuthService(auth.OAuthConfig{
		PublicBaseURL: "https://wiki.example.com",
		ClientID: "client",
		AllowedEmails: []string{"daniel@getorcha.com"},
		Verifier: verifier,
		Store: s, Now: func() time.Time { return now },
		SessionLifetime: 30 * 24 * time.Hour,
	})
	out, err := svc.FinishCallback(context.Background(), "code", state)
	if err != nil {
		t.Fatal(err)
	}
	if out.ReturnPath != "/doc/a.md" || out.UserID != "daniel@getorcha.com" {
		t.Fatalf("callback output = %#v", out)
	}
	if out.SessionToken == "" || out.CSRFToken == "" {
		t.Fatal("missing session/csrf token")
	}
	if verifier.gotPKCE != "verifier" {
		t.Fatalf("verifier received pkce = %q, want the original verifier (not the challenge)", verifier.gotPKCE)
	}
}

func TestOAuthCallbackRejectsBadIdentity(t *testing.T) {
	for name, claims := range map[string]auth.IDTokenClaims{
		"not allowed": {Email: "other@example.com", EmailVerified: true, Name: "Other"},
		"unverified": {Email: "daniel@getorcha.com", EmailVerified: false, Name: "Daniel"},
	} {
		t.Run(name, func(t *testing.T) {
			s := openAuthCollab(t)
			now := time.Unix(100, 0)
			state := "state-" + name
			if err := s.CreateOAuthState(collab.OAuthState{
				StateHash: auth.TokenHash(state), PKCEVerifier: "verifier", ReturnPath: "/",
				CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute),
			}); err != nil {
				t.Fatal(err)
			}
			svc := auth.NewOAuthService(auth.OAuthConfig{
				PublicBaseURL: "https://wiki.example.com",
				ClientID: "client",
				AllowedEmails: []string{"daniel@getorcha.com"},
				Verifier: &fakeVerifier{claims: claims},
				Store: s, Now: func() time.Time { return now },
				SessionLifetime: 30 * 24 * time.Hour,
			})
			if _, err := svc.FinishCallback(context.Background(), "code", state); err == nil {
				t.Fatal("expected callback rejection")
			}
		})
	}
}

// State must be unguessable, single-use, and time-bounded. These three
// rejection paths are listed verbatim in the spec testing section.
func TestOAuthCallbackRejectsUnknownState(t *testing.T) {
	s := openAuthCollab(t)
	now := time.Unix(100, 0)
	svc := auth.NewOAuthService(auth.OAuthConfig{
		PublicBaseURL: "https://wiki.example.com",
		ClientID:      "client",
		AllowedEmails: []string{"daniel@getorcha.com"},
		Verifier: &fakeVerifier{claims: auth.IDTokenClaims{
			Email: "daniel@getorcha.com", EmailVerified: true, Name: "Daniel",
		}},
		Store: s, Now: func() time.Time { return now },
		SessionLifetime: 30 * 24 * time.Hour,
	})
	// State was never stored.
	if _, err := svc.FinishCallback(context.Background(), "code", "never-stored"); err == nil {
		t.Fatal("expected rejection for unknown state")
	}
}

func TestOAuthCallbackRejectsExpiredState(t *testing.T) {
	s := openAuthCollab(t)
	now := time.Unix(100, 0)
	state := "expired"
	if err := s.CreateOAuthState(collab.OAuthState{
		StateHash: auth.TokenHash(state), PKCEVerifier: "verifier", ReturnPath: "/",
		CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute),
	}); err != nil {
		t.Fatal(err)
	}
	// Advance the clock past the 10-minute window.
	future := now.Add(11 * time.Minute)
	svc := auth.NewOAuthService(auth.OAuthConfig{
		PublicBaseURL: "https://wiki.example.com",
		ClientID:      "client",
		AllowedEmails: []string{"daniel@getorcha.com"},
		Verifier: &fakeVerifier{claims: auth.IDTokenClaims{
			Email: "daniel@getorcha.com", EmailVerified: true, Name: "Daniel",
		}},
		Store: s, Now: func() time.Time { return future },
		SessionLifetime: 30 * 24 * time.Hour,
	})
	if _, err := svc.FinishCallback(context.Background(), "code", state); err == nil {
		t.Fatal("expected rejection for expired state")
	}
}

func TestOAuthCallbackRejectsReplayedState(t *testing.T) {
	s := openAuthCollab(t)
	now := time.Unix(100, 0)
	state := "replayed"
	if err := s.CreateOAuthState(collab.OAuthState{
		StateHash: auth.TokenHash(state), PKCEVerifier: "verifier", ReturnPath: "/",
		CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute),
	}); err != nil {
		t.Fatal(err)
	}
	svc := auth.NewOAuthService(auth.OAuthConfig{
		PublicBaseURL: "https://wiki.example.com",
		ClientID:      "client",
		AllowedEmails: []string{"daniel@getorcha.com"},
		Verifier: &fakeVerifier{claims: auth.IDTokenClaims{
			Email: "daniel@getorcha.com", EmailVerified: true, Name: "Daniel",
		}},
		Store: s, Now: func() time.Time { return now },
		SessionLifetime: 30 * 24 * time.Hour,
	})
	if _, err := svc.FinishCallback(context.Background(), "code", state); err != nil {
		t.Fatalf("first call should succeed: %v", err)
	}
	if _, err := svc.FinishCallback(context.Background(), "code", state); err == nil {
		t.Fatal("expected rejection for replayed state (single-use)")
	}
}

func TestOAuthCallbackPropagatesVerifierError(t *testing.T) {
	s := openAuthCollab(t)
	now := time.Unix(100, 0)
	state := "state"
	if err := s.CreateOAuthState(collab.OAuthState{
		StateHash: auth.TokenHash(state), PKCEVerifier: "verifier", ReturnPath: "/",
		CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute),
	}); err != nil {
		t.Fatal(err)
	}
	svc := auth.NewOAuthService(auth.OAuthConfig{
		PublicBaseURL: "https://wiki.example.com",
		ClientID: "client",
		AllowedEmails: []string{"daniel@getorcha.com"},
		Verifier: &fakeVerifier{err: errors.New("exchange failed")},
		Store: s, Now: func() time.Time { return now },
		SessionLifetime: 30 * 24 * time.Hour,
	})
	if _, err := svc.FinishCallback(context.Background(), "code", state); err == nil {
		t.Fatal("expected verifier error")
	}
}

func TestOAuthCallbackNormalizesDisplayName(t *testing.T) {
	cases := map[string]struct {
		nameClaim string
		want      string
	}{
		"empty falls back to email": {nameClaim: "", want: "daniel@getorcha.com"},
		"whitespace falls back":     {nameClaim: "   ", want: "daniel@getorcha.com"},
		"long name is capped":       {nameClaim: strings.Repeat("A", 500), want: strings.Repeat("A", 200)},
	}
	for label, tc := range cases {
		t.Run(label, func(t *testing.T) {
			s := openAuthCollab(t)
			now := time.Unix(100, 0)
			state := "state-" + label
			if err := s.CreateOAuthState(collab.OAuthState{
				StateHash: auth.TokenHash(state), PKCEVerifier: "verifier", ReturnPath: "/",
				CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute),
			}); err != nil {
				t.Fatal(err)
			}
			svc := auth.NewOAuthService(auth.OAuthConfig{
				PublicBaseURL: "https://wiki.example.com",
				ClientID:      "client",
				AllowedEmails: []string{"daniel@getorcha.com"},
				Verifier: &fakeVerifier{claims: auth.IDTokenClaims{
					Email: "daniel@getorcha.com", EmailVerified: true, Name: tc.nameClaim,
				}},
				Store: s, Now: func() time.Time { return now },
				SessionLifetime: 30 * 24 * time.Hour,
			})
			out, err := svc.FinishCallback(context.Background(), "code", state)
			if err != nil {
				t.Fatal(err)
			}
			if out.DisplayName != tc.want {
				t.Fatalf("DisplayName = %q, want %q", out.DisplayName, tc.want)
			}
		})
	}
}

func TestIDTokenClaimsJSONTags(t *testing.T) {
	// Google emits snake_case. Without `json:"email_verified"` etc., every
	// real login silently fails the verified check.
	payload := `{"email":"daniel@getorcha.com","email_verified":true,"name":"Daniel"}`
	var got auth.IDTokenClaims
	if err := json.Unmarshal([]byte(payload), &got); err != nil {
		t.Fatal(err)
	}
	if got.Email != "daniel@getorcha.com" || !got.EmailVerified || got.Name != "Daniel" {
		t.Fatalf("claims = %#v; JSON tags are missing or wrong", got)
	}
}

func openAuthCollab(t *testing.T) *collab.Store {
	t.Helper()
	s, err := collab.Open(collab.Config{Path: filepath.Join(t.TempDir(), "auth.db")})
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = s.Close() })
	return s
}

Run: go test ./internal/auth/... -run OAuth -v

Expected: FAIL because OAuth types do not exist.

Create internal/auth/oauth.go:

package auth

import (
	"context"
	"crypto/sha256"
	"encoding/base64"
	"fmt"
	"net/url"
	"strings"
	"time"

	"github.com/getorcha/wiki-browser/internal/collab"
	"github.com/coreos/go-oidc/v3/oidc"
	"golang.org/x/oauth2"
)

// IDTokenClaims mirrors the subset of Google ID-token claims we use. JSON
// tags are MANDATORY — `oidc.IDToken.Claims` unmarshals via encoding/json,
// and Google emits snake_case (e.g. `email_verified`). Without tags every
// real login silently fails the verified check while fixture tests pass.
type IDTokenClaims struct {
	Email         string `json:"email"`
	EmailVerified bool   `json:"email_verified"`
	Name          string `json:"name"`
}

type TokenVerifier interface {
	ExchangeAndVerify(ctx context.Context, code, pkceVerifier string) (IDTokenClaims, error)
}

type Store interface {
	CreateUserAndSession(collab.User, collab.Session) error
	CreateOAuthState(collab.OAuthState) error
	ConsumeOAuthState(string, time.Time) (collab.OAuthState, bool, error)
}

// OAuthConfig wires the OAuth flow. The Google client secret lives only on
// the TokenVerifier (e.g. GoogleVerifier in this package) — OAuthService
// doesn't perform the token exchange itself, so it doesn't need the secret.
// Secret rotation requires a process restart so a fresh verifier picks up
// the new value from disk.
type OAuthConfig struct {
	PublicBaseURL   string
	ClientID        string
	AllowedEmails   []string
	Verifier        TokenVerifier
	Store           Store
	Now             func() time.Time
	SessionLifetime time.Duration
}

type OAuthService struct {
	publicBaseURL   string
	clientID        string
	allowed         map[string]bool
	verifier        TokenVerifier
	store           Store
	now             func() time.Time
	sessionLifetime time.Duration
}

func NewOAuthService(cfg OAuthConfig) *OAuthService {
	allowed := make(map[string]bool, len(cfg.AllowedEmails))
	for _, email := range cfg.AllowedEmails {
		allowed[strings.ToLower(strings.TrimSpace(email))] = true
	}
	now := cfg.Now
	if now == nil {
		now = time.Now
	}
	lifetime := cfg.SessionLifetime
	if lifetime == 0 {
		lifetime = 30 * 24 * time.Hour
	}
	return &OAuthService{
		publicBaseURL: cfg.PublicBaseURL, clientID: cfg.ClientID,
		allowed: allowed, verifier: cfg.Verifier, store: cfg.Store, now: now, sessionLifetime: lifetime,
	}
}

func (s *OAuthService) StartLogin(ctx context.Context, returnPath string) (string, error) {
	state, err := RandomToken(32)
	if err != nil {
		return "", err
	}
	verifier, err := RandomToken(32)
	if err != nil {
		return "", err
	}
	now := s.now()
	if err := s.store.CreateOAuthState(collab.OAuthState{
		StateHash: TokenHash(state), PKCEVerifier: verifier,
		ReturnPath: SafeReturnPath(returnPath), CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute),
	}); err != nil {
		return "", err
	}
	u, err := url.Parse("https://accounts.google.com/o/oauth2/v2/auth")
	if err != nil {
		return "", err
	}
	q := u.Query()
	q.Set("client_id", s.clientID)
	q.Set("redirect_uri", s.publicBaseURL+"/auth/callback")
	q.Set("response_type", "code")
	q.Set("scope", "openid email profile")
	q.Set("state", state)
	q.Set("code_challenge", codeChallengeS256(verifier))
	q.Set("code_challenge_method", "S256")
	u.RawQuery = q.Encode()
	_ = ctx
	return u.String(), nil
}

func codeChallengeS256(verifier string) string {
	sum := sha256.Sum256([]byte(verifier))
	return base64.RawURLEncoding.EncodeToString(sum[:])
}

type CallbackResult struct {
	ReturnPath   string
	UserID       string
	DisplayName  string
	SessionToken string
	CSRFToken    string
	ExpiresAt    time.Time
}

func (s *OAuthService) FinishCallback(ctx context.Context, code, state string) (CallbackResult, error) {
	now := s.now()
	oauthState, ok, err := s.store.ConsumeOAuthState(TokenHash(state), now)
	if err != nil {
		return CallbackResult{}, err
	}
	if !ok {
		return CallbackResult{}, fmt.Errorf("oauth state invalid or expired")
	}
	claims, err := s.verifier.ExchangeAndVerify(ctx, code, oauthState.PKCEVerifier)
	if err != nil {
		return CallbackResult{}, err
	}
	email := strings.ToLower(strings.TrimSpace(claims.Email))
	if !claims.EmailVerified {
		return CallbackResult{}, fmt.Errorf("email is not verified")
	}
	if !s.allowed[email] {
		return CallbackResult{}, fmt.Errorf("email is not allowed")
	}
	displayName := strings.TrimSpace(claims.Name)
	if displayName == "" {
		displayName = email
	}
	// Cap at 200 runes. Google can return very long display names; the
	// column has no CHECK constraint and we render this string in chrome.
	const maxDisplayName = 200
	if r := []rune(displayName); len(r) > maxDisplayName {
		displayName = string(r[:maxDisplayName])
	}
	issued, err := IssueSession(s.store, email, displayName, now, s.sessionLifetime)
	if err != nil {
		return CallbackResult{}, err
	}
	issued.ReturnPath = oauthState.ReturnPath
	return issued, nil
}

// SessionIssuer is the subset of the collab store needed by IssueSession.
// Kept narrow so the dev-mode picker can call it with the same Store value
// passed to OAuthService.
type SessionIssuer interface {
	CreateUserAndSession(collab.User, collab.Session) error
}

// IssueSession mints a fresh session + CSRF token pair, upserts the user,
// and persists the session row in a single transaction. Shared by the OAuth
// callback (`FinishCallback`) and the dev-mode login handler so the produced
// session is byte-for-byte interchangeable regardless of how identity was
// proved. The returned CallbackResult has ReturnPath unset; callers fill it
// in from their own state (OAuth state row, dev-mode query param, etc.).
func IssueSession(store SessionIssuer, userID, displayName string, now time.Time, lifetime time.Duration) (CallbackResult, error) {
	sessionToken, err := RandomToken(32)
	if err != nil {
		return CallbackResult{}, err
	}
	csrfToken, err := RandomToken(32)
	if err != nil {
		return CallbackResult{}, err
	}
	expiresAt := now.Add(lifetime)
	// Upsert the user and create the session atomically. Two separate
	// statements left a window where a concurrent process could alter the
	// users row between insert and FK lookup. The transactional helper also
	// guarantees that if the session insert fails we don't leave a
	// half-bootstrapped user row behind.
	if err := store.CreateUserAndSession(
		collab.User{ID: userID, DisplayName: displayName},
		collab.Session{
			IDHash: TokenHash(sessionToken), UserID: userID, CSRFHash: TokenHash(csrfToken),
			CreatedAt: now, LastSeenAt: now, ExpiresAt: expiresAt,
		},
	); err != nil {
		return CallbackResult{}, err
	}
	return CallbackResult{
		UserID: userID, DisplayName: displayName,
		SessionToken: sessionToken, CSRFToken: csrfToken, ExpiresAt: expiresAt,
	}, nil
}

type GoogleVerifier struct {
	oauth2Config *oauth2.Config
	verifier     *oidc.IDTokenVerifier
}

func NewGoogleVerifier(ctx context.Context, clientID, clientSecret, publicBaseURL string) (*GoogleVerifier, error) {
	provider, err := oidc.NewProvider(ctx, "https://accounts.google.com")
	if err != nil {
		return nil, err
	}
	return &GoogleVerifier{
		oauth2Config: &oauth2.Config{
			ClientID: clientID, ClientSecret: clientSecret,
			Endpoint: provider.Endpoint(),
			RedirectURL: publicBaseURL + "/auth/callback",
			Scopes: []string{oidc.ScopeOpenID, "email", "profile"},
		},
		verifier: provider.Verifier(&oidc.Config{ClientID: clientID}),
	}, nil
}

func (g *GoogleVerifier) ExchangeAndVerify(ctx context.Context, code, pkceVerifier string) (IDTokenClaims, error) {
	token, err := g.oauth2Config.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", pkceVerifier))
	if err != nil {
		return IDTokenClaims{}, err
	}
	rawIDToken, ok := token.Extra("id_token").(string)
	if !ok {
		return IDTokenClaims{}, fmt.Errorf("missing id_token")
	}
	idToken, err := g.verifier.Verify(ctx, rawIDToken)
	if err != nil {
		return IDTokenClaims{}, err
	}
	var claims IDTokenClaims
	if err := idToken.Claims(&claims); err != nil {
		return IDTokenClaims{}, err
	}
	return claims, nil
}

PKCE uses S256 end-to-end: StartLogin SHA-256-hashes the stored verifier into a 43-char base64url challenge sent to Google; FinishCallback passes the original verifier to ExchangeAndVerify, which forwards it to Google as code_verifier. Never send code_challenge_method=plain — proxy logs and referer leakage make the unhashed verifier directly exfiltrable from the auth URL.

Run: go test ./internal/auth/... -run OAuth -v

Expected: PASS.

git add go.mod go.sum internal/auth/oauth.go internal/auth/oauth_test.go
git commit -m "wiki-browser: auth — oauth service"

Task 6: Middleware and Auth Handlers

Files:

Create internal/auth/middleware_test.go:

package auth_test

import (
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"testing"
	"time"

	"github.com/getorcha/wiki-browser/internal/auth"
	"github.com/getorcha/wiki-browser/internal/collab"
)

func TestSessionMiddlewareLoadsPrincipal(t *testing.T) {
	store := newMemorySessionStore(t)
	now := time.Unix(100, 0)
	if err := store.UpsertUser(collab.User{ID: "daniel@getorcha.com", DisplayName: "Daniel"}); err != nil {
		t.Fatal(err)
	}
	if err := store.CreateSession(collab.Session{
		IDHash: auth.TokenHash("session"), UserID: "daniel@getorcha.com", CSRFHash: auth.TokenHash("csrf"),
		CreatedAt: now, LastSeenAt: now, ExpiresAt: now.Add(time.Hour),
	}); err != nil {
		t.Fatal(err)
	}
	called := false
	next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		called = true
		p, ok := auth.PrincipalFrom(r.Context())
		if !ok || p.UserID != "daniel@getorcha.com" {
			t.Fatalf("principal = %#v ok=%v", p, ok)
		}
	})
	h := auth.SessionMiddleware(store, func() time.Time { return now }, time.Hour)(next)
	req := httptest.NewRequest("GET", "/", nil)
	req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: "session"})
	h.ServeHTTP(httptest.NewRecorder(), req)
	if !called {
		t.Fatal("next was not called")
	}
}

// SessionMiddleware must NOT touch the DB on every request — that funnels
// authenticated reads through the SQLite write path and contends with Topic
// writes. A touch is only allowed once per SessionTouchInterval. Asserted
// via the persisted last_seen_at: a touched session has the column moved
// forward; a throttled request leaves it alone.
func TestSessionMiddlewareThrottlesTouch(t *testing.T) {
	store := newMemorySessionStore(t)
	created := time.Unix(1_000_000, 0)
	idHash := auth.TokenHash("session")
	if err := store.UpsertUser(collab.User{ID: "daniel@getorcha.com", DisplayName: "Daniel"}); err != nil {
		t.Fatal(err)
	}
	if err := store.CreateSession(collab.Session{
		IDHash: idHash, UserID: "daniel@getorcha.com", CSRFHash: auth.TokenHash("csrf"),
		CreatedAt: created, LastSeenAt: created, ExpiresAt: created.Add(24 * time.Hour),
	}); err != nil {
		t.Fatal(err)
	}
	current := created.Add(time.Minute)
	mw := auth.SessionMiddleware(store, func() time.Time { return current }, 24*time.Hour)
	req := httptest.NewRequest("GET", "/", nil)
	req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: "session"})
	mw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(httptest.NewRecorder(), req)
	got, ok, err := store.LookupSession(idHash, current.Add(time.Second))
	if err != nil || !ok {
		t.Fatalf("LookupSession ok=%v err=%v", ok, err)
	}
	if !got.LastSeenAt.Equal(created) {
		t.Fatalf("last_seen_at advanced before SessionTouchInterval: got %v want %v", got.LastSeenAt, created)
	}
	current = created.Add(auth.SessionTouchInterval)
	mw = auth.SessionMiddleware(store, func() time.Time { return current }, 24*time.Hour)
	mw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(httptest.NewRecorder(), req)
	got, ok, err = store.LookupSession(idHash, current.Add(time.Second))
	if err != nil || !ok {
		t.Fatalf("LookupSession after touch ok=%v err=%v", ok, err)
	}
	if !got.LastSeenAt.Equal(current) {
		t.Fatalf("last_seen_at not advanced after SessionTouchInterval: got %v want %v", got.LastSeenAt, current)
	}
}

func TestCSRFMiddleware(t *testing.T) {
	req := httptest.NewRequest("POST", "/api/x", nil)
	req = req.WithContext(auth.WithSession(req.Context(), auth.SessionInfo{
		IDHash: "session", CSRFHash: auth.TokenHash("csrf"),
	}))
	req.Header.Set("X-CSRF-Token", "csrf")
	rr := httptest.NewRecorder()
	called := false
	auth.RequireCSRF(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		called = true
	})).ServeHTTP(rr, req)
	if !called || rr.Code != http.StatusOK {
		t.Fatalf("called=%v status=%d", called, rr.Code)
	}

	bad := httptest.NewRequest("POST", "/api/x", nil)
	bad = bad.WithContext(auth.WithSession(bad.Context(), auth.SessionInfo{CSRFHash: auth.TokenHash("csrf")}))
	rr = httptest.NewRecorder()
	auth.RequireCSRF(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		t.Fatal("next should not run")
	})).ServeHTTP(rr, bad)
	if rr.Code != http.StatusForbidden {
		t.Fatalf("status = %d, want 403", rr.Code)
	}
}

Add this helper to the same file:

func newMemorySessionStore(t *testing.T) *collab.Store {
	t.Helper()
	s, err := collab.Open(collab.Config{Path: filepath.Join(t.TempDir(), "auth.db")})
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = s.Close() })
	return s
}

Create internal/auth/handlers_test.go:

package auth_test

import (
	"context"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/getorcha/wiki-browser/internal/auth"
	"github.com/getorcha/wiki-browser/internal/collab"
)

type fakeOAuth struct {
	startURL string
	result   auth.CallbackResult
	err      error
}

func (f fakeOAuth) StartLogin(_ context.Context, _ string) (string, error) {
	return f.startURL, f.err
}

func (f fakeOAuth) FinishCallback(_ context.Context, _, _ string) (auth.CallbackResult, error) {
	return f.result, f.err
}

func TestLoginRedirects(t *testing.T) {
	h := auth.NewHandlers(auth.HandlerConfig{
		OAuth: fakeOAuth{startURL: "https://accounts.google.com/o/oauth2/v2/auth?state=s"},
	})
	rr := httptest.NewRecorder()
	h.Login(rr, httptest.NewRequest("GET", "/auth/login?return=/doc/a.md", nil))
	if rr.Code != http.StatusFound {
		t.Fatalf("status = %d", rr.Code)
	}
	if got := rr.Header().Get("Location"); !strings.HasPrefix(got, "https://accounts.google.com/") {
		t.Fatalf("Location = %q", got)
	}
}

// In dev mode, /auth/login MUST NOT redirect to Google. It must render the
// in-browser picker so the developer can pick which allowlisted user to be
// without round-tripping through the OAuth provider. The picker page links
// to /auth/dev/login?as=<email>&return=<path> for each allowed email,
// preserving the original return path through the picker.
func TestLoginRendersDevPicker(t *testing.T) {
	h := auth.NewHandlers(auth.HandlerConfig{
		DevMode:       true,
		AllowedEmails: []string{"daniel@getorcha.com", "max@getorcha.com"},
	})
	rr := httptest.NewRecorder()
	h.Login(rr, httptest.NewRequest("GET", "/auth/login?return=/doc/a.md", nil))
	if rr.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", rr.Code)
	}
	body := rr.Body.String()
	for _, want := range []string{
		"DEV MODE",
		`/auth/dev/login?as=daniel@getorcha.com&amp;return=/doc/a.md`,
		`/auth/dev/login?as=max@getorcha.com&amp;return=/doc/a.md`,
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("picker missing %q in body: %s", want, body)
		}
	}
}

// /auth/callback is dead in dev mode: Google never redirects there because
// the picker bypasses the OAuth flow. 404 (not 500) is the right answer for
// a stale bookmark or a misrouted curl while the binary is running locally.
func TestCallbackReturnsNotFoundInDevMode(t *testing.T) {
	h := auth.NewHandlers(auth.HandlerConfig{DevMode: true})
	rr := httptest.NewRecorder()
	h.Callback(rr, httptest.NewRequest("GET", "/auth/callback?code=c&state=s", nil))
	if rr.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want 404", rr.Code)
	}
}

// /auth/dev/login is the end of the dev-mode picker click. It MUST issue
// real wb_session + wb_csrf cookies — byte-identical in shape to what the
// OAuth callback issues — and redirect to a safe return path. Tested
// end-to-end against a real *collab.Store so the same SessionIssuer path
// used in production is exercised. Secure=false because dev_mode runs over
// http://localhost; browsers reject Secure cookies on plain http.
func TestDevLoginIssuesSession(t *testing.T) {
	store := openAuthCollab(t)
	now := time.Unix(1_000_000, 0)
	h := auth.NewHandlers(auth.HandlerConfig{
		Store:           store,
		SessionIssuer:   store,
		DevMode:         true,
		AllowedEmails:   []string{"daniel@getorcha.com", "max@getorcha.com"},
		SessionLifetime: 24 * time.Hour,
		Now:             func() time.Time { return now },
	})
	rr := httptest.NewRecorder()
	h.DevLogin(rr, httptest.NewRequest("GET", "/auth/dev/login?as=daniel@getorcha.com&return=/doc/a.md", nil))
	if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/doc/a.md" {
		t.Fatalf("status=%d location=%q", rr.Code, rr.Header().Get("Location"))
	}
	var session, csrf *http.Cookie
	for _, c := range rr.Result().Cookies() {
		switch c.Name {
		case auth.SessionCookieName:
			session = c
		case auth.CSRFCookieName:
			csrf = c
		}
	}
	if session == nil || csrf == nil {
		t.Fatalf("missing cookies: session=%#v csrf=%#v", session, csrf)
	}
	if !session.HttpOnly || csrf.HttpOnly {
		t.Fatalf("HttpOnly flags wrong: session=%v csrf=%v", session.HttpOnly, csrf.HttpOnly)
	}
	if session.Secure || csrf.Secure {
		t.Fatalf("Secure must be false in dev mode over http: session=%v csrf=%v", session.Secure, csrf.Secure)
	}
	// Issued session must be looked up via the same hash path the middleware
	// uses, proving the dev-mode handler and OAuth callback agree on the
	// session-storage contract.
	if _, ok, err := store.LookupSession(auth.TokenHash(session.Value), now.Add(time.Second)); err != nil || !ok {
		t.Fatalf("LookupSession ok=%v err=%v", ok, err)
	}
}

func TestDevLoginRejectsUnknownEmail(t *testing.T) {
	store := openAuthCollab(t)
	h := auth.NewHandlers(auth.HandlerConfig{
		Store: store, SessionIssuer: store,
		DevMode: true, AllowedEmails: []string{"daniel@getorcha.com"},
		Now: func() time.Time { return time.Unix(100, 0) },
	})
	rr := httptest.NewRecorder()
	h.DevLogin(rr, httptest.NewRequest("GET", "/auth/dev/login?as=intruder@example.com", nil))
	if rr.Code != http.StatusForbidden {
		t.Fatalf("status = %d, want 403", rr.Code)
	}
	if cookies := rr.Result().Cookies(); len(cookies) > 0 {
		t.Fatalf("rejected dev login set cookies: %v", cookies)
	}
}

// Defensive: even if a future refactor accidentally registers /auth/dev/login
// outside dev mode, the handler must refuse. The route-level guard in
// server.go is the primary defense; this is belt-and-braces.
func TestDevLoginReturnsNotFoundWhenNotDevMode(t *testing.T) {
	h := auth.NewHandlers(auth.HandlerConfig{
		AllowedEmails: []string{"daniel@getorcha.com"},
	})
	rr := httptest.NewRecorder()
	h.DevLogin(rr, httptest.NewRequest("GET", "/auth/dev/login?as=daniel@getorcha.com", nil))
	if rr.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want 404", rr.Code)
	}
}

func TestCallbackSetsSessionCookie(t *testing.T) {
	expires := time.Unix(1000, 0)
	h := auth.NewHandlers(auth.HandlerConfig{
		OAuth: fakeOAuth{result: auth.CallbackResult{
			ReturnPath: "/doc/a.md", UserID: "daniel@getorcha.com", DisplayName: "Daniel",
			SessionToken: "session-token", CSRFToken: "csrf-token", ExpiresAt: expires,
		}},
	})
	rr := httptest.NewRecorder()
	h.Callback(rr, httptest.NewRequest("GET", "/auth/callback?code=c&state=s", nil))
	if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/doc/a.md" {
		t.Fatalf("status=%d location=%q", rr.Code, rr.Header().Get("Location"))
	}
	cookies := rr.Result().Cookies()
	var session, csrf *http.Cookie
	for _, c := range cookies {
		switch c.Name {
		case auth.SessionCookieName:
			session = c
		case auth.CSRFCookieName:
			csrf = c
		}
	}
	if session == nil || !session.HttpOnly || !session.Secure || session.SameSite != http.SameSiteLaxMode {
		t.Fatalf("session cookie = %#v", session)
	}
	if csrf == nil || csrf.HttpOnly || !csrf.Secure || csrf.SameSite != http.SameSiteLaxMode {
		t.Fatalf("csrf cookie = %#v (must be Secure SameSite=Lax but NOT HttpOnly)", csrf)
	}
	if csrf.Value != "csrf-token" {
		t.Fatalf("csrf cookie value = %q, want raw token from CallbackResult", csrf.Value)
	}
}

// Me must NOT rotate the CSRF token. It reads the companion cookie and
// echoes the raw value so JS can pin it for the session lifetime. Rotation
// on read would break multi-tab use: tab A's first /auth/me invalidates
// tab B's token, and tab B's next mutating click 403s.
func TestMeReturnsStableCSRF(t *testing.T) {
	store, session := handlerStoreWithSession(t)
	req := httptest.NewRequest("GET", "/auth/me", nil)
	req.AddCookie(&http.Cookie{Name: auth.CSRFCookieName, Value: "stable-csrf"})
	req = req.WithContext(auth.WithPrincipal(req.Context(), auth.Principal{
		UserID: "daniel@getorcha.com", DisplayName: "Daniel",
	}))
	req = req.WithContext(auth.WithSession(req.Context(), session))
	rr := httptest.NewRecorder()
	auth.NewHandlers(auth.HandlerConfig{Store: store}).Me(rr, req)
	body := rr.Body.String()
	if !strings.Contains(body, `"authenticated":true`) || !strings.Contains(body, `"csrf_token":"stable-csrf"`) {
		t.Fatalf("body = %s", body)
	}
	got, ok, err := store.LookupSession(session.IDHash, time.Unix(101, 0))
	if err != nil || !ok {
		t.Fatalf("LookupSession ok=%v err=%v", ok, err)
	}
	if got.CSRFHash != session.CSRFHash {
		t.Fatal("Me rotated csrf hash; expected no rotation when companion cookie matches")
	}
}

// When the companion cookie is missing (browser cleared it, proxy stripped
// it) Me re-mints. This is the only rotation path; it does not fire on
// normal reads.
func TestMeRotatesCSRFOnMissingCompanion(t *testing.T) {
	store, session := handlerStoreWithSession(t)
	req := httptest.NewRequest("GET", "/auth/me", nil) // no CSRF cookie
	req = req.WithContext(auth.WithPrincipal(req.Context(), auth.Principal{
		UserID: "daniel@getorcha.com", DisplayName: "Daniel",
	}))
	req = req.WithContext(auth.WithSession(req.Context(), session))
	rr := httptest.NewRecorder()
	auth.NewHandlers(auth.HandlerConfig{Store: store}).Me(rr, req)
	body := rr.Body.String()
	if !strings.Contains(body, `"csrf_token":"`) {
		t.Fatalf("body = %s", body)
	}
	// New cookie set on the response.
	var setCookie *http.Cookie
	for _, c := range rr.Result().Cookies() {
		if c.Name == auth.CSRFCookieName {
			setCookie = c
		}
	}
	if setCookie == nil || setCookie.Value == "" || setCookie.HttpOnly {
		t.Fatalf("new csrf companion = %#v", setCookie)
	}
	got, ok, err := store.LookupSession(session.IDHash, time.Unix(101, 0))
	if err != nil || !ok {
		t.Fatalf("LookupSession ok=%v err=%v", ok, err)
	}
	if got.CSRFHash == session.CSRFHash {
		t.Fatal("Me did not rotate csrf hash on missing companion")
	}
}

// Logout must succeed even when the session is already expired/revoked. A
// stale tab clicking sign-out should clear the local cookies, not 403.
func TestLogoutIsIdempotent(t *testing.T) {
	h := auth.NewHandlers(auth.HandlerConfig{Now: func() time.Time { return time.Unix(100, 0) }})
	rr := httptest.NewRecorder()
	// No SessionInfo in context, no CSRF header — represents an expired or
	// already-cleared session.
	h.Logout(rr, httptest.NewRequest("POST", "/auth/logout", nil))
	if rr.Code != http.StatusSeeOther {
		t.Fatalf("status = %d, want 303", rr.Code)
	}
	var cleared []string
	for _, c := range rr.Result().Cookies() {
		if c.MaxAge < 0 {
			cleared = append(cleared, c.Name)
		}
	}
	if len(cleared) != 2 {
		t.Fatalf("cleared cookies = %v, want both session and csrf cleared", cleared)
	}
}

// With a real session in context, logout still requires a valid CSRF
// header — the idempotence is for the no-session branch only.
func TestLogoutRequiresCSRFWhenSessionPresent(t *testing.T) {
	store, session := handlerStoreWithSession(t)
	h := auth.NewHandlers(auth.HandlerConfig{Store: store, Now: func() time.Time { return time.Unix(100, 0) }})
	rr := httptest.NewRecorder()
	req := httptest.NewRequest("POST", "/auth/logout", nil)
	req = req.WithContext(auth.WithSession(req.Context(), session))
	// No X-CSRF-Token header → 403.
	h.Logout(rr, req)
	if rr.Code != http.StatusForbidden {
		t.Fatalf("status = %d, want 403 without csrf", rr.Code)
	}
	// With matching header → 303 and DB revoke fires.
	rr = httptest.NewRecorder()
	req = httptest.NewRequest("POST", "/auth/logout", nil)
	req = req.WithContext(auth.WithSession(req.Context(), session))
	req.Header.Set("X-CSRF-Token", "old-csrf")
	h.Logout(rr, req)
	if rr.Code != http.StatusSeeOther {
		t.Fatalf("status = %d, want 303 with valid csrf", rr.Code)
	}
	if _, ok, _ := store.LookupSession(session.IDHash, time.Unix(101, 0)); ok {
		t.Fatal("session not revoked after authenticated logout")
	}
}

func handlerStoreWithSession(t *testing.T) (*collab.Store, auth.SessionInfo) {
	t.Helper()
	store, err := collab.Open(collab.Config{Path: filepath.Join(t.TempDir(), "auth.db")})
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = store.Close() })
	now := time.Unix(100, 0)
	if err := store.UpsertUser(collab.User{ID: "daniel@getorcha.com", DisplayName: "Daniel"}); err != nil {
		t.Fatal(err)
	}
	idHash := auth.TokenHash("session")
	csrfHash := auth.TokenHash("old-csrf")
	if err := store.CreateSession(collab.Session{
		IDHash: idHash, UserID: "daniel@getorcha.com", CSRFHash: csrfHash,
		CreatedAt: now, LastSeenAt: now, ExpiresAt: now.Add(time.Hour),
	}); err != nil {
		t.Fatal(err)
	}
	return store, auth.SessionInfo{IDHash: idHash, CSRFHash: csrfHash}
}

Run: go test ./internal/auth/... -run 'TestSessionMiddleware|TestCSRF|TestLogin|TestCallback|TestMe' -v

Expected: FAIL because middleware/handlers do not exist.

Create internal/auth/middleware.go:

package auth

import (
	"context"
	"crypto/subtle"
	"log/slog"
	"net/http"
	"time"

	"github.com/getorcha/wiki-browser/internal/collab"
)

// SessionTouchInterval throttles writes to auth_sessions.last_seen_at. The
// middleware only calls TouchSession when the session's recorded last_seen_at
// is older than this. Doing it per-request would force every authenticated
// read through the single SQLite write funnel, contending with Topic writes.
const SessionTouchInterval = 10 * time.Minute

const (
	// SessionCookieName is the HttpOnly session cookie. JS cannot read it.
	SessionCookieName = "wb_session"
	// CSRFCookieName is the non-HttpOnly companion cookie carrying the raw
	// CSRF token so same-origin JS can echo it back via X-CSRF-Token. The
	// pair lives and dies together: set at login, cleared at logout, never
	// rotated on read. Stable for the session lifetime so multi-tab use
	// works without one tab invalidating another tab's token.
	CSRFCookieName = "wb_csrf"
)

type SessionStore interface {
	LookupSession(string, time.Time) (collab.Session, bool, error)
	TouchSession(string, time.Time, time.Time) error
	RevokeSession(string, time.Time) error
	RotateSessionCSRF(string, string) error
}

type sessionKey struct{}

type SessionInfo struct {
	IDHash    string
	CSRFHash  string
}

func WithSession(ctx context.Context, s SessionInfo) context.Context {
	return context.WithValue(ctx, sessionKey{}, s)
}

func SessionFrom(ctx context.Context) (SessionInfo, bool) {
	s, ok := ctx.Value(sessionKey{}).(SessionInfo)
	return s, ok
}

// SessionMiddleware loads the principal from the session cookie and refreshes
// the sliding expiry. `sessionLifetime` is the rolling TTL applied on touch;
// pass 0 to use the package default (30 days). Touches are throttled to once
// per SessionTouchInterval to keep authenticated reads off the write funnel.
func SessionMiddleware(store SessionStore, now func() time.Time, sessionLifetime time.Duration) func(http.Handler) http.Handler {
	if now == nil {
		now = time.Now
	}
	if sessionLifetime <= 0 {
		sessionLifetime = 30 * 24 * time.Hour
	}
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			cookie, err := r.Cookie(SessionCookieName)
			if err != nil || cookie.Value == "" {
				next.ServeHTTP(w, r)
				return
			}
			current := now()
			idHash := TokenHash(cookie.Value)
			session, ok, err := store.LookupSession(idHash, current)
			if err != nil || !ok {
				next.ServeHTTP(w, r)
				return
			}
			if current.Sub(session.LastSeenAt) >= SessionTouchInterval {
				if err := store.TouchSession(idHash, current, current.Add(sessionLifetime)); err != nil {
					// Touch failure is non-fatal — the request can still
					// proceed under the existing expiry — but it shouldn't
					// be silent: a chronic failure means sessions don't
					// slide and will expire mid-use.
					slog.Warn("session touch failed", "err", err)
				}
			}
			ctx := WithPrincipal(r.Context(), Principal{UserID: session.UserID, DisplayName: session.UserID})
			ctx = WithSession(ctx, SessionInfo{IDHash: idHash, CSRFHash: session.CSRFHash})
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

func RequireCSRF(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		session, ok := SessionFrom(r.Context())
		if !ok || session.CSRFHash == "" {
			writeAuthJSON(w, http.StatusForbidden, "csrf_required")
			return
		}
		got := TokenHash(r.Header.Get("X-CSRF-Token"))
		if subtle.ConstantTimeCompare([]byte(got), []byte(session.CSRFHash)) != 1 {
			writeAuthJSON(w, http.StatusForbidden, "csrf_invalid")
			return
		}
		next.ServeHTTP(w, r)
	})
}

Create internal/auth/handlers.go:

package auth

import (
	"context"
	"crypto/subtle"
	"encoding/json"
	"html/template"
	"net/http"
	"strings"
	"time"
	"unicode"
)

type OAuthFlow interface {
	StartLogin(context.Context, string) (string, error)
	FinishCallback(context.Context, string, string) (CallbackResult, error)
}

type HandlerConfig struct {
	OAuth OAuthFlow
	Store SessionStore
	Now   func() time.Time
	// Dev-mode picker. When DevMode is true, Login renders an on-page
	// "Continue as <email>" selector instead of redirecting to Google, and
	// DevLogin issues a real session via IssueSession. Both fields are
	// unused when DevMode is false. SessionIssuer is normally the same
	// *collab.Store value backing Store; the narrower interface keeps the
	// helper portable for tests.
	DevMode         bool
	AllowedEmails   []string
	SessionIssuer   SessionIssuer
	SessionLifetime time.Duration
	// CookieSecure controls Secure on the session/CSRF cookies. Defaults to
	// true in production; main.go sets it to false when DevMode is on and
	// public_base_url is http (browsers reject Secure cookies over http).
	CookieSecure bool
}

type Handlers struct {
	oauth           OAuthFlow
	store           SessionStore
	now             func() time.Time
	devMode         bool
	devAllowed      map[string]bool
	devEmails       []string // preserved input order for stable picker rendering
	issuer          SessionIssuer
	sessionLifetime time.Duration
	cookieSecure    bool
}

func NewHandlers(cfg HandlerConfig) *Handlers {
	now := cfg.Now
	if now == nil {
		now = time.Now
	}
	lifetime := cfg.SessionLifetime
	if lifetime == 0 {
		lifetime = 30 * 24 * time.Hour
	}
	allowed := make(map[string]bool, len(cfg.AllowedEmails))
	for _, email := range cfg.AllowedEmails {
		allowed[strings.ToLower(strings.TrimSpace(email))] = true
	}
	return &Handlers{
		oauth: cfg.OAuth, store: cfg.Store, now: now,
		devMode: cfg.DevMode, devAllowed: allowed, devEmails: cfg.AllowedEmails,
		issuer: cfg.SessionIssuer, sessionLifetime: lifetime,
		cookieSecure: cfg.CookieSecure || !cfg.DevMode,
	}
}

// devPickerTemplate is intentionally inline. The dev picker is a 6-line page
// shown only to a developer running locally — moving it into the server
// templates package would pull internal/auth's only HTML coupling out of the
// package boundary for marginal benefit.
var devPickerTemplate = template.Must(template.New("dev-picker").Parse(`<!doctype html>
<meta charset="utf-8">
<title>wiki-browser — dev sign in</title>
<style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem}h1{font-size:1.25rem}.warn{background:#fef3c7;border-left:3px solid #b45309;padding:.5rem .75rem;margin:1rem 0;font-size:.9rem}a.btn{display:block;margin:.5rem 0;padding:.6rem .75rem;background:#1c1917;color:#fafaf9;border-radius:4px;text-decoration:none}a.btn:hover{background:#44403c}</style>
<h1>wiki-browser — dev sign in</h1>
<p class="warn"><strong>DEV MODE.</strong> Client-trusted identity. Never enabled in production.</p>
{{range .Emails}}<a class="btn" href="/auth/dev/login?as={{.Email}}&return={{$.Return}}">Continue as {{.Email}}</a>
{{end}}`))

type devPickerEntry struct{ Email string }
type devPickerData struct {
	Emails []devPickerEntry
	Return string
}

func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) {
	if h.devMode {
		w.Header().Set("Cache-Control", "no-store")
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		entries := make([]devPickerEntry, 0, len(h.devEmails))
		for _, email := range h.devEmails {
			entries = append(entries, devPickerEntry{Email: email})
		}
		_ = devPickerTemplate.Execute(w, devPickerData{
			Emails: entries,
			Return: SafeReturnPath(r.URL.Query().Get("return")),
		})
		return
	}
	redirect, err := h.oauth.StartLogin(r.Context(), r.URL.Query().Get("return"))
	if err != nil {
		http.Error(w, "login failed", http.StatusInternalServerError)
		return
	}
	http.Redirect(w, r, redirect, http.StatusFound)
}

func (h *Handlers) Callback(w http.ResponseWriter, r *http.Request) {
	if h.devMode {
		// Google never redirects here when dev_mode is on. Returning 404
		// surfaces accidental hits (a stale browser bookmark, a misrouted
		// curl) instead of confusing 500s from a nil verifier.
		http.NotFound(w, r)
		return
	}
	out, err := h.oauth.FinishCallback(r.Context(), r.URL.Query().Get("code"), r.URL.Query().Get("state"))
	if err != nil {
		http.Error(w, "This Google account is not allowed", http.StatusForbidden)
		return
	}
	h.setSessionCookies(w, out)
	http.Redirect(w, r, SafeReturnPath(out.ReturnPath), http.StatusFound)
}

// DevLogin issues a session for one of the dev-mode allowlisted emails. Gated
// on h.devMode AND the emergency route-level gate in server.go — the
// production binary should never register this path, but the handler is
// defensive in case it is wired in by mistake.
func (h *Handlers) DevLogin(w http.ResponseWriter, r *http.Request) {
	if !h.devMode {
		http.NotFound(w, r)
		return
	}
	email := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("as")))
	if !h.devAllowed[email] {
		http.Error(w, "email not in allowed_emails", http.StatusForbidden)
		return
	}
	displayName := devDisplayName(email)
	out, err := IssueSession(h.issuer, email, displayName, h.now(), h.sessionLifetime)
	if err != nil {
		http.Error(w, "login failed", http.StatusInternalServerError)
		return
	}
	h.setSessionCookies(w, out)
	http.Redirect(w, r, SafeReturnPath(r.URL.Query().Get("return")), http.StatusFound)
}

func (h *Handlers) setSessionCookies(w http.ResponseWriter, out CallbackResult) {
	http.SetCookie(w, &http.Cookie{
		Name: SessionCookieName, Value: out.SessionToken, Path: "/",
		Expires: out.ExpiresAt, HttpOnly: true, Secure: h.cookieSecure,
		SameSite: http.SameSiteLaxMode,
	})
	// Companion cookie: same expiry, NOT HttpOnly so same-origin JS can read
	// it and echo via X-CSRF-Token. SameSite=Lax blocks cross-site cookie
	// inclusion; HttpOnly on the session cookie blocks JS exfiltration of
	// the session itself. The CSRF cookie value is the defense-in-depth
	// signal that a request originated from page JS on this origin.
	http.SetCookie(w, &http.Cookie{
		Name: CSRFCookieName, Value: out.CSRFToken, Path: "/",
		Expires: out.ExpiresAt, HttpOnly: false, Secure: h.cookieSecure,
		SameSite: http.SameSiteLaxMode,
	})
}

// devDisplayName turns daniel@getorcha.com into "Daniel" for first-time
// sessions. The user row is upserted, so a later OAuth login that supplies a
// real Google name claim still replaces this placeholder.
func devDisplayName(email string) string {
	local, _, ok := strings.Cut(email, "@")
	if !ok || local == "" {
		return email
	}
	r := []rune(local)
	r[0] = unicode.ToUpper(r[0])
	return string(r)
}

func (h *Handlers) Me(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	p, ok := PrincipalFrom(r.Context())
	if !ok {
		_ = json.NewEncoder(w).Encode(map[string]any{"authenticated": false})
		return
	}
	session, ok := SessionFrom(r.Context())
	if !ok || h.store == nil {
		writeAuthJSON(w, http.StatusUnauthorized, "unauthorized")
		return
	}
	// Read the companion cookie. Stable for the session lifetime — no
	// rotation on read. If the companion is missing (rare: browser cleared
	// it, proxy stripped it, or session predates the companion-cookie
	// rollout) mint a replacement and update the stored hash. That single
	// case is the only path that rotates the token.
	csrf := ""
	if c, err := r.Cookie(CSRFCookieName); err == nil && c.Value != "" {
		if subtle.ConstantTimeCompare([]byte(TokenHash(c.Value)), []byte(session.CSRFHash)) == 1 {
			csrf = c.Value
		}
	}
	if csrf == "" {
		fresh, err := RandomToken(32)
		if err != nil {
			http.Error(w, "csrf failed", http.StatusInternalServerError)
			return
		}
		if err := h.store.RotateSessionCSRF(session.IDHash, TokenHash(fresh)); err != nil {
			http.Error(w, "csrf failed", http.StatusInternalServerError)
			return
		}
		http.SetCookie(w, &http.Cookie{
			Name: CSRFCookieName, Value: fresh, Path: "/",
			HttpOnly: false, Secure: true, SameSite: http.SameSiteLaxMode,
		})
		csrf = fresh
	}
	_ = json.NewEncoder(w).Encode(map[string]any{
		"authenticated": true,
		"user": p,
		"csrf_token": csrf,
	})
}

func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
	// Always clear cookies, even if no session is in context (expired,
	// already-revoked, never logged in). The endpoint is intentionally
	// idempotent so logout from a stale tab does the right thing.
	session, hasSession := SessionFrom(r.Context())
	if hasSession {
		// Session present: validate CSRF before mutating DB state. Same
		// constant-time compare as RequireCSRF — we just need to do it
		// inline so the no-session branch can fall through to cookie
		// clearing instead of 403'ing.
		got := TokenHash(r.Header.Get("X-CSRF-Token"))
		if subtle.ConstantTimeCompare([]byte(got), []byte(session.CSRFHash)) != 1 {
			writeAuthJSON(w, http.StatusForbidden, "csrf_invalid")
			return
		}
		if h.store != nil {
			_ = h.store.RevokeSession(session.IDHash, h.now())
		}
	}
	http.SetCookie(w, &http.Cookie{
		Name: SessionCookieName, Value: "", Path: "/",
		MaxAge: -1, HttpOnly: true, Secure: true,
		SameSite: http.SameSiteLaxMode,
	})
	http.SetCookie(w, &http.Cookie{
		Name: CSRFCookieName, Value: "", Path: "/",
		MaxAge: -1, HttpOnly: false, Secure: true,
		SameSite: http.SameSiteLaxMode,
	})
	http.Redirect(w, r, SafeReturnPath(r.FormValue("return")), http.StatusSeeOther)
}

Run: go test ./internal/auth/... ./internal/collab/... -run 'TestSessionMiddleware|TestCSRF|TestLogin|TestCallback|TestMe|TestSessionLifecycle' -v

Expected: PASS.

git add internal/auth/middleware.go internal/auth/middleware_test.go internal/auth/handlers.go internal/auth/handlers_test.go internal/collab/auth_store.go internal/collab/auth_store_test.go
git commit -m "wiki-browser: auth — middleware and handlers"

Task 7: Server Wiring, Cache Headers, and Auth Chrome

Files:

Create internal/server/auth_integration_test.go:

package server

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

// Route through the package's Mux constructor, NOT handleRoot directly.
// Calling the handler bypasses the SessionMiddleware wrapping in Mux, so a
// test that "works" against the handler can still mis-wire the route in
// production.
func TestShellShowsSignInWhenAnonymous(t *testing.T) {
	root := t.TempDir()
	d := testDeps(t, root)
	rr := httptest.NewRecorder()
	Mux(d).ServeHTTP(rr, httptest.NewRequest("GET", "/", nil))
	body := rr.Body.String()
	if !strings.Contains(body, `/auth/login`) {
		t.Fatalf("anonymous shell missing sign-in link: %s", body)
	}
	if strings.Contains(body, "Topics") {
		t.Fatalf("anonymous shell should not render collaborator UI: %s", body)
	}
}

func TestShellCacheHeaders(t *testing.T) {
	root := t.TempDir()
	d := testDeps(t, root)
	rr := httptest.NewRecorder()
	Mux(d).ServeHTTP(rr, httptest.NewRequest("GET", "/", nil))
	if got := rr.Header().Get("Cache-Control"); got != "no-store" {
		t.Fatalf("Cache-Control = %q", got)
	}
	if got := rr.Header().Get("Vary"); got != "Cookie" {
		t.Fatalf("Vary = %q", got)
	}
}

// End-to-end cookie chain. Asserts that the Callback handler issues the
// pair (`wb_session` HttpOnly + `wb_csrf` non-HttpOnly) and that those
// cookies, replayed on a subsequent request, light up the authenticated
// rendering path. This is the test that would have caught the original
// redirect-URI concatenation bug and the missing JSON-tags bug if they
// had survived to the integration layer.
func TestAuthFlowCookiesAuthenticateSubsequentRequest(t *testing.T) {
	root := t.TempDir()
	d := testDepsAuthenticated(t, root, "daniel@getorcha.com", "Daniel")
	mux := Mux(d)
	rr := httptest.NewRecorder()
	mux.ServeHTTP(rr, httptest.NewRequest("GET", "/", nil))
	if got := rr.Header().Get("Vary"); got != "Cookie" {
		t.Fatalf("authenticated shell Vary = %q, want Cookie", got)
	}
	if got := rr.Header().Get("Cache-Control"); got != "no-store" {
		t.Fatalf("authenticated shell Cache-Control = %q, want no-store", got)
	}
	body := rr.Body.String()
	if strings.Contains(body, "/auth/login") {
		t.Fatalf("signed-in shell still rendering sign-in link: %s", body)
	}
	if !strings.Contains(body, `id="wb-topic-sidebar"`) {
		t.Fatalf("signed-in shell missing topic sidebar")
	}
}

Update internal/server/templates_test.go so TestRenderShell_emitsIframe uses anonymous ShellData and expects no id="wb-topic-sidebar". Add a second test with Authenticated: true that asserts the current Topic Core markup still renders for signed-in users:

func TestRenderShell_authenticatedEmitsTopicSidebar(t *testing.T) {
	tpl := mustTemplates()
	var b strings.Builder
	err := tpl.ExecuteTemplate(&b, "shell.html", ShellData{
		Title: "Test", ContentPath: "/content/a.md", CurrentPath: "a.md",
		Authenticated: true, UserDisplayName: "Daniel",
	})
	if err != nil {
		t.Fatal(err)
	}
	out := b.String()
	for _, want := range []string{`id="wb-topic-sidebar"`, `id="wb-topic-list"`, `id="wb-new-global-topic"`} {
		if !strings.Contains(out, want) {
			t.Errorf("signed-in shell missing %q", want)
		}
	}
}

Also update TestRenderContentMD_emitsProseAndContent to pass Authenticated: true when it expects the wb-source-sha meta tag. Add an anonymous content rendering assertion that a non-empty SourceSHA is not emitted unless Authenticated is true.

If testDeps does not exist, create it in this file using the pattern from existing server tests:

func testDeps(t *testing.T, root string) Deps {
	t.Helper()
	w, err := walker.New(walker.Options{Root: root, Extensions: []string{".md", ".html"}})
	if err != nil {
		t.Fatal(err)
	}
	return Deps{Title: "Test", Root: root, Walker: w, Cache: render.NewCache(1 << 20)}
}

// testDepsAuthenticated wires Deps with a SessionMiddleware that injects a
// fixed principal — used to drive end-to-end shell rendering as a signed-in
// user without standing up the real OAuth handlers in tests.
func testDepsAuthenticated(t *testing.T, root, userID, displayName string) Deps {
	t.Helper()
	d := testDeps(t, root)
	d.SessionMiddleware = func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx := auth.WithPrincipal(r.Context(), auth.Principal{
				UserID: userID, DisplayName: displayName,
			})
			ctx = auth.WithSession(ctx, auth.SessionInfo{
				IDHash: "test-session", CSRFHash: auth.TokenHash("test-csrf"),
			})
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
	return d
}

Add imports for github.com/getorcha/wiki-browser/internal/render and github.com/getorcha/wiki-browser/internal/walker.

Run: go test ./internal/server/... -run 'TestShellShowsSignIn|TestShellCacheHeaders|TestRenderShell' -v

Expected: FAIL because shell has no sign-in affordance/cache headers.

In internal/server/server.go, add imports:

	"github.com/getorcha/wiki-browser/internal/auth"
	"github.com/getorcha/wiki-browser/internal/collab"

Extend Deps:

	Collab *collab.Store
	Auth   *auth.Handlers
	SessionMiddleware func(http.Handler) http.Handler
	// AuthDevMode mirrors cfg.Auth.DevMode so the router can decide whether
	// to register /auth/dev/login. The handler also defends itself with 404
	// when devMode is false (see TestDevLoginReturnsNotFoundWhenNotDevMode),
	// but the route-level gate is the primary boundary: the production
	// binary should never even attach the path.
	AuthDevMode bool

Register auth routes before content/doc routes:

	if d.Auth != nil {
		mux.HandleFunc("GET /auth/login", d.Auth.Login)
		mux.HandleFunc("GET /auth/callback", d.Auth.Callback)
		mux.Handle("GET /auth/me", d.withSession(http.HandlerFunc(d.Auth.Me)))
		// Logout is idempotent: clicking sign-out with an expired session
		// must still succeed. The route skips RequireCollaborator and
		// RequireCSRF; the handler does its own session-aware CSRF check
		// (validates when a session is present; no-ops when absent) and
		// always clears the cookies.
		mux.Handle("POST /auth/logout", d.withSession(http.HandlerFunc(d.Auth.Logout)))
		if d.AuthDevMode {
			// Dev-mode picker submission. Plain GET — there is no
			// pre-existing session for CSRF to bind to, same as the OAuth
			// callback. Config-load already refuses dev_mode + https, so
			// this path can only exist on a local http origin.
			mux.HandleFunc("GET /auth/dev/login", d.Auth.DevLogin)
		}
	}

Add helper:

func (d Deps) withSession(h http.Handler) http.Handler {
	if d.SessionMiddleware == nil {
		return h
	}
	return d.SessionMiddleware(h)
}

For current public routes, keep them public; call d.withSession only when the handler needs optional principal data for rendering:

	mux.Handle("GET /content/", d.withSession(http.HandlerFunc(d.handleContent)))
	mux.Handle("GET /doc/", d.withSession(http.HandlerFunc(d.handleDoc)))
	mux.Handle("GET /{$}", d.withSession(http.HandlerFunc(d.handleRoot)))

In internal/server/embed.go, extend ShellData:

	Authenticated bool
	UserDisplayName string
	LoginPath string

Also extend ContentMDData:

	Authenticated bool

In internal/server/handler_doc.go, import github.com/getorcha/wiki-browser/internal/auth.

At the start of writeShell, set no-store headers:

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("Vary", "Cookie")

Before executing template:

	var authenticated bool
	var displayName string
	if p, ok := auth.PrincipalFrom(r.Context()); ok {
		authenticated = true
		displayName = p.DisplayName
	}

This requires changing writeShell signature to accept r *http.Request:

func (d Deps) writeShell(w http.ResponseWriter, r *http.Request, status int, currentPath, contentPath string)

Update callers in handleRoot and handleDoc.

Set template data:

		Authenticated: authenticated,
		UserDisplayName: displayName,
		LoginPath: "/auth/login?return=" + url.QueryEscape(r.URL.RequestURI()),

Add net/url import.

In internal/server/templates/shell.html, add this block inside .wb-topbar after the search input:

    <div class="wb-auth">
      {{ if .Authenticated }}
        <span class="wb-auth-user">{{ .UserDisplayName }}</span>
        <button id="wb-logout" class="wb-auth-button" type="button">Sign out</button>
      {{ else }}
        <a class="wb-auth-button" href="{{ .LoginPath }}">Sign in</a>
      {{ end }}
    </div>

Wrap the existing #wb-topic-sidebar section so anonymous shells do not render any collaborator UI:

    {{ if .Authenticated }}
    <section
      id="wb-topic-sidebar"
      class="wb-topic-sidebar"
      data-current-path="{{ .CurrentPath }}"
      aria-label="Topics">
      ...
    </section>
    {{ end }}

Keep the iframe and public navigation exactly as they are for anonymous users.

In internal/server/static/chrome.js, near the top after existing const declarations:

  const logout = document.getElementById('wb-logout');
  const topicSidebar = document.getElementById('wb-topic-sidebar');
  // Auth gate: when the shell renders for an anonymous user it omits BOTH
  // #wb-logout and #wb-topic-sidebar. Short-circuit before any auth/topic
  // wiring so anonymous loads don't issue /auth/me, /api/topics, or any
  // CSRF-header mutating fetch. (Public search, keyboard nav, and iframe
  // wiring continue further down outside this guard.)
  const authenticated = logout !== null && topicSidebar !== null;
  // Seed CSRF synchronously from the non-HttpOnly companion cookie so the
  // first mutating click doesn't race the /auth/me roundtrip. refreshAuth()
  // still runs to pick up server-side adjustments (companion-missing
  // rotation case), but it's no longer load-bearing for first-click safety.
  function readCookie(name) {
    const prefix = name + '=';
    for (const part of document.cookie.split('; ')) {
      if (part.startsWith(prefix)) return decodeURIComponent(part.slice(prefix.length));
    }
    return '';
  }
  let csrfToken = authenticated ? readCookie('wb_csrf') : '';

Add after helper functions:

  async function refreshAuth() {
    try {
      const res = await fetch('/auth/me', { credentials: 'same-origin', cache: 'no-store' });
      if (!res.ok) return;
      const data = await res.json();
      // Stable for the session lifetime — Me only mints a new value when
      // the companion cookie is missing. The value here matches what we
      // already read from document.cookie unless rotation just happened.
      csrfToken = data.csrf_token || csrfToken;
    } catch (_) {}
  }

  if (logout) {
    refreshAuth();
    logout.addEventListener('click', async () => {
      const body = new URLSearchParams({ return: location.pathname + location.search });
      await fetch('/auth/logout', {
        method: 'POST',
        credentials: 'same-origin',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          'X-CSRF-Token': csrfToken,
        },
        body,
      });
      location.reload();
    });
  }

Thread the CSRF token through current Topic Core requests:

  function csrfHeaders(base) {
    return Object.assign({}, base || {}, csrfToken ? { 'X-CSRF-Token': csrfToken } : {});
  }

Gate refreshAuth() and every topic-related binding on the authenticated flag computed above. Anonymous loads must NOT call /auth/me, /api/topics, or /api/topics/{id}/messages. With the companion cookie seeded synchronously at module load, csrfToken is non-empty as soon as the script runs (for authenticated users), so first-click mutations no longer race /auth/me. The refresh is still worth doing — it picks up server-side rotations when the companion cookie was missing — but it is no longer a hard prerequisite before mutations. Wrap the existing loadTopics/refreshAuth/logout setup in if (authenticated) { ... }.

Update fetch calls:

  const resp = await fetch('/api/topics?source_path=' + encodeURIComponent(sourcePath), {
    credentials: 'same-origin',
    cache: 'no-store',
  });
  const resp = await fetch('/api/topics', {
    method: 'POST',
    credentials: 'same-origin',
    headers: csrfHeaders({ 'Content-Type': 'application/json' }),
    body: JSON.stringify(payload)
  });
  const resp = await fetch('/api/topics/' + encodeURIComponent(selectedTopicID) + '/messages', {
    method: 'POST',
    credentials: 'same-origin',
    headers: csrfHeaders({ 'Content-Type': 'application/json' }),
    body: JSON.stringify({ body: topicReply.value.trim() })
  });

The corresponding topic-message GET (inside openTopicThread) becomes:

  const resp = await fetch('/api/topics/' + encodeURIComponent(selectedTopicID) + '/messages', {
    credentials: 'same-origin',
    cache: 'no-store',
  });

In internal/server/static/chrome.css, add compact topbar styles:

.wb-auth {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-left: auto;
  white-space: nowrap;
}

.wb-auth-user {
  color: var(--wb-muted);
  font-size: 13px;
}

.wb-auth-button {
  border: 1px solid var(--wb-rule);
  background: var(--wb-surface);
  color: var(--wb-text);
  border-radius: 4px;
  padding: 6px 10px;
  font: inherit;
  font-size: 13px;
  text-decoration: none;
  cursor: pointer;
}

In internal/server/handler_content.go, before every successful content/raw response:

	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("Vary", "Cookie")

Keep /static cache behavior unchanged. Do not add collaborator data to /search in this task.

Because /content/... is now public but session-aware, only resolve Topic anchors and expose the source SHA to collaborator code when a principal is present:

	authenticated := false
	if _, ok := auth.PrincipalFrom(r.Context()); ok {
		authenticated = true
	}
	if authenticated && d.Collab != nil && urlPath != "" && !strings.HasPrefix(urlPath, "_") {
		// existing ListOpenTopicsForSource + render.ResolveAnchors block
	}
	sourceSHA := ""
	if authenticated {
		sourceSHA = sha
	}

Pass Authenticated: authenticated and SourceSHA: sourceSHA into ContentMDData.

In internal/server/templates/content_md.html, make the body carry auth state and keep the source SHA absent for anonymous users:

{{ if and .Authenticated .SourceSHA }}<meta name="wb-source-sha" content="{{ .SourceSHA }}">{{ end }}
<body class="wb-prose" data-title="{{ .Title }}" {{ if .Authenticated }}data-authenticated="true" data-source-sha="{{ .SourceSHA }}"{{ end }}>

In internal/server/static/content.js, guard the collaborator-only behavior:

  const authenticated = document.body.dataset.authenticated === 'true';
  ...
  if (!authenticated) return; // before installing selection composer and anchor click handlers

Leave the existing key-forwarding behavior active for everyone.

In cmd/wiki-browser/main.go, replace collab.Open call with:

	collabStore, err := collab.Open(collab.Config{Path: cfg.CollabDB})
	if err != nil {
		return fmt.Errorf("open collab: %w", err)
	}
	// Eviction pass: removing an email from auth.allowed_emails + restart
	// must actually kick that user out. Without this, a removed user keeps
	// their session up to the sliding lifetime.
	if revoked, err := collabStore.RevokeSessionsNotIn(cfg.Auth.AllowedEmails, time.Now()); err != nil {
		return fmt.Errorf("revoke non-allowlisted sessions: %w", err)
	} else if revoked > 0 {
		slog.Info("revoked sessions for non-allowlisted users", "count", revoked)
	}

Read secrets and wire auth — dev mode skips the Google verifier entirely:

	const sessionLifetime = 30 * 24 * time.Hour
	var oauthSvc *auth.OAuthService
	cookieSecure := true
	if cfg.Auth.DevMode {
		// Loud, parseable startup banner. Config-load already refused
		// dev_mode + https, so this branch is only ever reached locally.
		slog.Warn("DEV MODE ENABLED — /auth/login renders the on-page user picker; OAuth is bypassed; do not enable on a public deployment")
		// Browsers reject Secure cookies over plain http. Dev runs against
		// http://localhost so Secure must be false for the cookies to stick.
		cookieSecure = false
	} else {
		clientSecretRaw, err := os.ReadFile(cfg.Auth.GoogleClientSecretFile)
		if err != nil {
			return fmt.Errorf("read google client secret: %w", err)
		}
		// Trim once. The secret lives only on the GoogleVerifier (which
		// performs the token exchange); OAuthService does not need it.
		// Secret rotation requires a process restart so the verifier is
		// rebuilt from disk.
		clientSecret := strings.TrimSpace(string(clientSecretRaw))
		verifier, err := auth.NewGoogleVerifier(rootCtx, cfg.Auth.GoogleClientID, clientSecret, cfg.Auth.PublicBaseURL)
		if err != nil {
			return fmt.Errorf("google verifier: %w", err)
		}
		oauthSvc = auth.NewOAuthService(auth.OAuthConfig{
			PublicBaseURL:   cfg.Auth.PublicBaseURL,
			ClientID:        cfg.Auth.GoogleClientID,
			AllowedEmails:   cfg.Auth.AllowedEmails,
			Verifier:        verifier,
			Store:           collabStore,
			SessionLifetime: sessionLifetime,
		})
	}
	authHandlers := auth.NewHandlers(auth.HandlerConfig{
		OAuth:           oauthSvc,
		Store:           collabStore,
		DevMode:         cfg.Auth.DevMode,
		AllowedEmails:   cfg.Auth.AllowedEmails,
		SessionIssuer:   collabStore,
		SessionLifetime: sessionLifetime,
		CookieSecure:    cookieSecure,
	})
	sessionMiddleware := auth.SessionMiddleware(collabStore, time.Now, sessionLifetime)

Add imports:

	"log/slog"
	"strings"
	"github.com/getorcha/wiki-browser/internal/auth"

Pass into server deps:

		Collab: collabStore,
		Auth: authHandlers,
		SessionMiddleware: sessionMiddleware,
		AuthDevMode: cfg.Auth.DevMode,

Note: create rootCtx before constructing Google verifier, since it needs context. Move the signal.NotifyContext block above auth wiring.

Run: go test ./internal/server/... ./cmd/wiki-browser/... -v

Expected: PASS.

Run: go test ./... -v

Expected: PASS.

git add cmd/wiki-browser/main.go internal/server internal/auth internal/collab wiki-browser.example.yaml go.mod go.sum
git commit -m "wiki-browser: auth — wire routes and chrome"

Task 8: Protect Merged Topic Core APIs

Files:

The bootstrap operator is gone. Run:

grep -rn "OperatorUserID" cmd internal

Expected: zero hits after this step. Touch each call site listed in the result:

If the grep still shows hits after this step, compilation in Step 4 will fail.

The merged Topic Core tests currently call Topic APIs anonymously. Update the server test helper so existing Topic API behavior tests run as Daniel with a valid test CSRF token.

In internal/server/handler_doc_test.go, drop any remaining OperatorUserID field set on collab.Config or server.Deps (the Step 0 sweep should have cleared these already; this is the safety net). Add an import for github.com/getorcha/wiki-browser/internal/auth.

Add this helper:

func testSessionMiddleware(userID, displayName, csrf string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx := auth.WithPrincipal(r.Context(), auth.Principal{
				UserID: userID, DisplayName: displayName,
			})
			if csrf != "" {
				ctx = auth.WithSession(ctx, auth.SessionInfo{
					IDHash: "test-session", CSRFHash: auth.TokenHash(csrf),
				})
			}
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

Set it in newTestServer:

SessionMiddleware: testSessionMiddleware("daniel@getorcha.com", "Daniel", "csrf"),

In internal/server/topics_test.go, add X-CSRF-Token: csrf to every mutating request whose assertion should reach the Topic handler: successful topic/message creation, invalid-create validation, stale-source validation, closed-topic setup, and closed-topic append.

req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/topics", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", "csrf")
resp, err := http.DefaultClient.Do(req)

Use the same pattern for POST /api/topics/{id}/messages.

Update TestTopicsAPI_returnsUnavailableWhenCollabDisabled so it still tests d.Collab == nil rather than the auth wrapper. Build the mux with authenticated test middleware:

mux := server.Mux(server.Deps{
	SessionMiddleware: testSessionMiddleware("daniel@getorcha.com", "Daniel", "csrf"),
})

Set X-CSRF-Token: csrf on the two POST cases in that test.

Append to internal/server/topics_test.go:

func TestTopicsAPI_requiresAuth(t *testing.T) {
	ts, _, _ := newAnonymousTestServer(t)
	for _, tc := range []struct {
		method string
		path   string
		body   string
	}{
		{http.MethodGet, "/api/topics?source_path=a.md", ""},
		{http.MethodPost, "/api/topics", `{"source_path":"a.md","global":true,"first_message_body":"x"}`},
		{http.MethodGet, "/api/topics/t1/messages", ""},
		{http.MethodPost, "/api/topics/t1/messages", `{"body":"x"}`},
	} {
		req, err := http.NewRequest(tc.method, ts.URL+tc.path, strings.NewReader(tc.body))
		if err != nil {
			t.Fatal(err)
		}
		if tc.body != "" {
			req.Header.Set("Content-Type", "application/json")
		}
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			t.Fatal(err)
		}
		resp.Body.Close()
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("%s %s status = %d, want 401", tc.method, tc.path, resp.StatusCode)
		}
	}
}

func TestTopicsAPI_mutationsRequireCSRF(t *testing.T) {
	ts, _, _ := newTestServerWithoutCSRF(t)
	resp, err := http.Post(ts.URL+"/api/topics", "application/json", strings.NewReader(
		`{"source_path":"a.md","global":true,"first_message_body":"hi"}`))
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusForbidden {
		t.Fatalf("status = %d, want 403; body=%s", resp.StatusCode, readAll(t, resp))
	}
}

Add helpers beside newTestServer:

func newAnonymousTestServer(t *testing.T) (*httptest.Server, string, *collab.Store) {
	return newTestServerWithSession(t, nil)
}

func newTestServerWithoutCSRF(t *testing.T) (*httptest.Server, string, *collab.Store) {
	mw := testSessionMiddleware("daniel@getorcha.com", "Daniel", "")
	return newTestServerWithSession(t, mw)
}

Refactor newTestServer to call newTestServerWithSession(t, testSessionMiddleware(...)). newTestServerWithSession should contain the existing temp-root/index/collab setup and assign SessionMiddleware: mw in server.Deps; mw == nil is the anonymous case.

In TestTopicsAPI_createGlobalListReply, after creating the topic, assert the database uses the request principal instead of the removed bootstrap operator:

var createdBy string
if err := store.RawDBForTest().QueryRow(
	`SELECT created_by FROM topics WHERE id = ?`, created.ID,
).Scan(&createdBy); err != nil {
	t.Fatal(err)
}
if createdBy != "daniel@getorcha.com" {
	t.Fatalf("created_by = %q", createdBy)
}

After appending the second message, assert its author:

var author string
if err := store.RawDBForTest().QueryRow(
	`SELECT author_user_id FROM topic_messages WHERE topic_id = ? AND sequence = 2`, created.ID,
).Scan(&author); err != nil {
	t.Fatal(err)
}
if author != "daniel@getorcha.com" {
	t.Fatalf("author_user_id = %q", author)
}

Run: go test ./internal/server/... -run 'TestTopicsAPI|TestDoc|TestRoot' -v

Expected: FAIL because Topic routes are not wrapped with auth/CSRF and topics.go still uses d.OperatorUserID.

In internal/server/server.go, replace the four Topic route registrations with:

	mux.Handle("POST /api/topics",
		d.withSession(auth.RequireCollaborator(auth.RequireCSRF(http.HandlerFunc(d.handleCreateTopic)))))
	mux.Handle("GET /api/topics",
		d.withSession(auth.RequireCollaborator(http.HandlerFunc(d.handleListTopics))))
	mux.Handle("GET /api/topics/{id}/messages",
		d.withSession(auth.RequireCollaborator(http.HandlerFunc(d.handleListTopicMessages))))
	mux.Handle("POST /api/topics/{id}/messages",
		d.withSession(auth.RequireCollaborator(auth.RequireCSRF(http.HandlerFunc(d.handleAppendTopicMessage)))))

This is safe for public deployments: if no valid session is loaded, protected Topic JSON returns 401; public document routes still serve.

In internal/server/topics.go, import github.com/getorcha/wiki-browser/internal/auth.

In handleCreateTopic, read the principal after validation:

	principal, _ := auth.PrincipalFrom(r.Context())

Replace:

CreatedBy: d.OperatorUserID,

with:

CreatedBy: principal.UserID,

In handleAppendTopicMessage, replace:

operator := d.OperatorUserID
AuthorUserID: &operator,

with:

principal, _ := auth.PrincipalFrom(r.Context())
author := principal.UserID
AuthorUserID: &author,

In writeJSON, add:

	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("Vary", "Cookie")

This makes protected Topic JSON concrete under the cache policy rather than relying on proxy defaults.

Run: go test ./internal/server/... -run 'TestTopicsAPI|TestDoc|TestRoot|TestShell' -v

Expected: PASS.

Run: go test ./... -v

Expected: PASS.

Run: make build

Expected: PASS and dist/wiki-browser exists.

Start the server with a local config that points auth secret files at local dummy files and public_base_url at the deployed HTTPS URL or a local HTTPS test origin. If a real Google client is not configured locally, only verify signed-out behavior.

Run:

playwright-cli open --browser=chromium http://localhost:8080/
playwright-cli eval "() => ({
  signIn: !!document.querySelector('a[href^=\"/auth/login\"]'),
  topics: document.body.textContent.includes('Topics'),
  iframe: !!document.getElementById('wb-content')
})"

Expected: output includes signIn: true, topics: false, iframe: true.

Then verify that anonymous chrome.js does NOT issue auth/topic fetches — they must 401, but should not be attempted at all:

playwright-cli network-log --pattern '/auth/me|/api/topics' --since-open

Expected: empty result. If anonymous JS calls either endpoint, the gate in chrome.js (if (authenticated)) was bypassed.

git add internal/server/server.go internal/server/topics.go internal/server/topics_test.go internal/server/handler_doc_test.go docs/superpowers/specs/2026-05-10-collaborative-annotations-decisions.md
git commit -m "wiki-browser: auth — protect topic APIs"

Cross-Task Notes


Self-Review Notes