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.
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.
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
Files:
Modify: internal/config/config.go
Modify: internal/config/config_test.go
Modify: internal/config/testdata/valid.yaml
Modify: internal/config/testdata/minimal.yaml
Modify: internal/config/testdata/missing-root.yaml
Modify: internal/config/testdata/bad-root.yaml
Modify: wiki-browser.example.yaml
Step 1: Update config fixture 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"
Files:
Modify: internal/collab/collab.go
Modify: internal/collab/collab_test.go
Create: internal/collab/migrations/002_auth.sql
Modify: internal/collab/migrate_test.go
Step 1: Add failing migration tests
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:
internal/collab/collab_test.gointernal/collab/migrate_test.gointernal/collab/mutators_test.gointernal/collab/reader_test.gointernal/collab/recover_test.gointernal/collab/incorporate_test.gointernal/collab/hashes_test.gointernal/collab/anchor_test.goPlus 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"
Files:
Create: internal/collab/auth_store.go
Create: internal/collab/auth_store_test.go
Step 1: Write failing storage tests
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"
Files:
Create: internal/auth/token.go
Create: internal/auth/token_test.go
Create: internal/auth/principal.go
Create: internal/auth/principal_test.go
Step 1: Write token tests
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"
Files:
Modify: go.mod
Modify: go.sum
Create: internal/auth/oauth.go
Create: internal/auth/oauth_test.go
Step 1: Add OAuth dependencies
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"
Files:
Create: internal/auth/middleware.go
Create: internal/auth/middleware_test.go
Create: internal/auth/handlers.go
Create: internal/auth/handlers_test.go
Step 1: Write middleware tests
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&return=/doc/a.md`,
`/auth/dev/login?as=max@getorcha.com&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"
Files:
Modify: cmd/wiki-browser/main.go
Modify: internal/server/server.go
Modify: internal/server/embed.go
Modify: internal/server/handler_doc.go
Modify: internal/server/handler_content.go
Modify: internal/server/templates/shell.html
Modify: internal/server/templates/content_md.html
Modify: internal/server/templates_test.go
Modify: internal/server/static/chrome.js
Modify: internal/server/static/content.js
Modify: internal/server/static/chrome.css
Create: internal/server/auth_integration_test.go
Step 1: Write server integration tests
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"
Files:
Modify: internal/server/server.go
Modify: internal/server/topics.go
Modify: internal/server/topics_test.go
Modify: internal/server/handler_doc_test.go
Modify: cmd/wiki-browser/main.go
Modify: docs/superpowers/specs/2026-05-10-collaborative-annotations-decisions.md only if implementation uncovers a cross-subproject decision not already recorded
Step 0: Sweep the OperatorUserID field
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:
internal/server/server.go: delete OperatorUserID string from Deps.cmd/wiki-browser/main.go: delete the OperatorUserID: cfg.Operator.UserID, line that currently survives in the Deps{...} literal — replace with principal-derived attribution wherever it was previously read.internal/server/handler_doc_test.go: delete OperatorUserID from any fixture server.Deps{...} literal.internal/server/topics.go: replace every d.OperatorUserID read with auth.PrincipalFrom(r.Context()).UserID (via the helper introduced in Step 3).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"
/static/, /search, /doc/..., /content/..., /, or /healthz with login. Only collaborator data and mutating behavior require auth.POST /api/topics with RequireCollaborator + RequireCSRFPOST /api/topics/{id}/messages with RequireCollaborator + RequireCSRFGET /api/topics and GET /api/topics/{id}/messages with RequireCollaboratorauth.PrincipalFrom(r.Context()).UserIDauth.dev_mode: true) replaces the OAuth round-trip with an on-page "Continue as <email>" picker. The picker page lives behind the same /auth/login URL the production binary uses; the server branches internally. Sessions issued by the dev-mode handler are byte-identical to OAuth-issued sessions (same IssueSession helper, same cookies, same middleware path). Config-load refuses to start when dev_mode: true AND public_base_url is HTTPS, so the Pi cannot accidentally run the client-trusting picker.users.id = email, hashed sessions, CSRF, OAuth state storage, cache headers, operator replacement, middleware, and testing are covered.auth_sessions.csrf_hash column stores SHA-256 of the raw token; the non-HttpOnly wb_csrf companion cookie carries the raw token to same-origin JS. The pair is set at login, cleared at logout, and stable for the session lifetime — /auth/me does NOT rotate on read (which would break multi-tab). The only rotation path is the missing-companion-cookie branch in Handlers.Me, which re-mints and updates the stored hash.hd (hosted-domain) claim is intentionally NOT checked. The spec calls it out as "may be checked as defense in depth" with the explicit caveat that allowlist remains authoritative. Since we already enforce exact-email match against auth.allowed_emails (case-folded, trimmed), accepting or rejecting on hd would only matter as a fast-fail before the allowlist lookup — and that lookup is already O(1). Skipping hd keeps the verifier code tight and avoids tying our auth to Workspace-only deployment semantics.nonce is intentionally NOT implemented. OIDC Core recommends nonce as ID-token replay protection in the implicit flow; for the authorization-code flow we use, the state parameter (random, hashed at rest, single-use, 10-minute TTL) plus PKCE S256 (verifier never on the wire, only the hash) plus email_verified plus exact-email allowlist already defeat code-replay and token-replay attacks against this app. Adding nonce would mean another column on auth_oauth_states and another check in FinishCallback for marginal extra defense in a two-person internal deployment. Revisit if the threat model widens (public-facing app, dynamic clients, mobile native).auth.allowed_emails + restart." Startup runs RevokeSessionsNotIn so removed users lose their sessions immediately on the next boot. Without the restart, a removed user keeps their session until natural expiry. The users row is not deleted — it remains as the FK target for prior collaborative actions; only sessions are revoked.go get requires network access. If sandboxed network fails, rerun with escalation and record the exact approved command in the task log.