Auth: opaque session cookie (never a naked DID)

closed
#40e284e opened by agent Sep 9

Goal

Replace the current sovrn_session cookie value DID|sessionID with an opaque token carrying no information. This is the security-critical fix and lands first.

Current state

internal/authbroker/handler.go sets sovrn_session = "<DID>|<sessionID>"; internal/appview/ui/session.go parses it back out. The DID is user-readable and the cookie is not tamper-evident.

Task

  • Mint a 256-bit CSPRNG token, base64url-encoded; the DID and indigo sessionID never appear in the cookie.
  • Server-side mapping table (SQLite, alongside the authbroker store): sessions(token PK, did, session_id, created_at, expires_at).
  • Cookie attributes per OWASP: HttpOnly; Secure; SameSite=Lax; Path=/ (consider __Host- prefix).
  • No signed/encrypted cookies — opaque token only, with no information in it.
  • ui.SessionAuth.authenticate: look up token → (did, sessionID) → broker.ResumeSession(did, sessionID) to validate/refresh; on miss redirect to /login.
  • Absolute expiry (30 d) with sliding refresh on use; regenerate on login; delete the row on logout.

Acceptance

  • Cookie contains no DID/session data (verified by inspection/fuzz).
  • A forged or expired token redirects to /login.
  • Session survives process restart (token persists in the DB).

Depends on: 70790ed S4 (app-view UI + session middleware).

2 Comments

agent 4a07e32 Sep 10

Opaque Session Cookie Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use checkbox (- [ ]) syntax.

Goal: Replace the DID|sessionID browser cookie with an opaque 256-bit token backed by a server-side SQLite mapping table whose key is sha256(token).

Architecture: The authbroker Store (same SQLite DB, oauth.db) gains a sessions table keyed by the SHA-256 hash of the opaque token (never the raw token). The Broker mints/validates tokens; the callback sets the cookie; ui.SessionAuth resolves token → (did, sessionID) → ResumeSession, with sliding expiry; a new /logout endpoint deletes the row and clears the cookie.

Tech Stack: Go stdlib (crypto/rand, crypto/sha256, encoding/base64, encoding/hex), database/sql + mattn/go-sqlite3, indigo oauth, a-h/templ.


Task 1: Token minting + SHA-256 hashing helpers (authbroker)

Files: - Create: internal/authbroker/session.go

  • [ ] Step 1: Write the failing test

Create internal/authbroker/session_test.go:

package authbroker

import (
	"encoding/base64"
	"strings"
	"testing"
)

func TestNewSessionTokenOpaque(t *testing.T) {
	seen := map[string]bool{}
	for range 200 {
		tok, err := newSessionToken()
		if err != nil {
			t.Fatalf("newSessionToken: %v", err)
		}
		if tok == "" {
			t.Fatal("empty token")
		}
		raw, err := base64.RawURLEncoding.DecodeString(tok)
		if err != nil {
			t.Fatalf("token %q is not base64url: %v", tok, err)
		}
		if len(raw) != 32 {
			t.Fatalf("token decodes to %d bytes, want 32 (256 bits)", len(raw))
		}
		for _, forbidden := range []string{"did:", "|"} {
			if strings.Contains(tok, forbidden) {
				t.Fatalf("token %q leaks %q", tok, forbidden)
			}
		}
		if seen[tok] {
			t.Fatalf("duplicate token minted: %q", tok)
		}
		seen[tok] = true
	}
}

func TestHashTokenDeterministic(t *testing.T) {
	if hashToken("abc") != hashToken("abc") {
		t.Fatal("hashToken is not deterministic")
	}
	if hashToken("abc") == hashToken("abd") {
		t.Fatal("hashToken collides on distinct inputs")
	}
	if got := hashToken("abc"); len(got) != 64 {
		t.Fatalf("sha256 hex length = %d, want 64", len(got))
	}
}
  • [ ] Step 2: Run test to verify it fails

Run: go test ./internal/authbroker/ -run 'TestNewSessionTokenOpaque|TestHashTokenDeterministic' Expected: FAIL (undefined: newSessionToken, undefined: hashToken)

  • [ ] Step 3: Write minimal implementation

Create internal/authbroker/session.go:

package authbroker

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"errors"
	"net/http"
	"time"

	"github.com/bluesky-social/indigo/atproto/syntax"
)

// SessionCookieName is the browser session cookie. Its value is an opaque
// token carrying no user data (docs/02 §4).
const SessionCookieName = "sovrn_session"

// sessionTTL is the absolute lifetime of a browser session (30 days, docs/02
// §7); every use extends it by this amount (sliding refresh).
const sessionTTL = 30 * 24 * time.Hour

// sessionTokenBytes is the token entropy: 256 bits.
const sessionTokenBytes = 32

// errNoSessionStore is returned when a Broker was built over a store that is
// not a *Store, so it cannot host the browser-session mapping table.
var errNoSessionStore = errors.New("authbroker: browser session store not configured")

// newSessionToken mints a 256-bit CSPRNG token, base64url-encoded (no padding).
func newSessionToken() (string, error) {
	var b [sessionTokenBytes]byte
	if _, err := rand.Read(b[:]); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b[:]), nil
}

// hashToken returns the hex-encoded SHA-256 of token. The raw token is never
// stored: the sessions table is keyed by this digest so a DB leak yields no
// reusable secrets.
func hashToken(token string) string {
	sum := sha256.Sum256([]byte(token))
	return hex.EncodeToString(sum[:])
}

// SetSessionCookie writes the opaque session token with OWASP-recommended
// attributes (HttpOnly, Secure, SameSite=Lax, Path=/).
func SetSessionCookie(w http.ResponseWriter, token string) {
	http.SetCookie(w, &http.Cookie{
		Name:     SessionCookieName,
		Value:    token,
		Path:     "/",
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteLaxMode,
		MaxAge:   int(sessionTTL.Seconds()),
	})
}

// ClearSessionCookie deletes the session cookie (logout).
func ClearSessionCookie(w http.ResponseWriter) {
	http.SetCookie(w, &http.Cookie{
		Name:     SessionCookieName,
		Value:    "",
		Path:     "/",
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteLaxMode,
		MaxAge:   -1,
		Expires:  time.Unix(1, 0),
	})
}

// IssueSession mints an opaque browser-session token for (did, sessionID) and
// persists the sha256(token) mapping with a 30-day absolute expiry.
func (b *Broker) IssueSession(ctx context.Context, did syntax.DID, sessionID string) (string, error) {
	if b.sessions == nil {
		return "", errNoSessionStore
	}
	token, err := newSessionToken()
	if err != nil {
		return "", err
	}
	if err := b.sessions.SaveSessionToken(ctx, token, did, sessionID, time.Now().UTC().Add(sessionTTL)); err != nil {
		return "", err
	}
	return token, nil
}

// GetSession resolves an opaque token to its (did, sessionID).
func (b *Broker) GetSession(ctx context.Context, token string) (syntax.DID, string, error) {
	if b.sessions == nil {
		return "", "", errNoSessionStore
	}
	return b.sessions.GetSessionToken(ctx, token)
}

// TouchSession extends the browser session's absolute expiry (sliding refresh).
func (b *Broker) TouchSession(ctx context.Context, token string) error {
	if b.sessions == nil {
		return errNoSessionStore
	}
	return b.sessions.TouchSessionToken(ctx, token, time.Now().UTC().Add(sessionTTL))
}

// DeleteSession removes the browser-session mapping (logout).
func (b *Broker) DeleteSession(ctx context.Context, token string) error {
	if b.sessions == nil {
		return errNoSessionStore
	}
	return b.sessions.DeleteSessionToken(ctx, token)
}
  • [ ] Step 4: Run test to verify it passes

Run: go test ./internal/authbroker/ -run 'TestNewSessionTokenOpaque|TestHashTokenDeterministic' Expected: PASS

  • [ ] Step 5: Commit
git add internal/authbroker/session.go internal/authbroker/session_test.go
git commit -m "feat(authbroker): opaque session token minting + sha256 hash helper"

Task 2: sessions table + Store methods (hashed key)

Files: - Modify: internal/authbroker/store.go

  • [ ] Step 1: Write the failing test

Append to internal/authbroker/session_test.go:

func TestSaveAndGetSessionToken(t *testing.T) {
	store := newTestStore(t)
	ctx := t.Context()
	did := testDID(t)
	token := "opaque-token-1"

	if err := store.SaveSessionToken(ctx, token, did, "session-1", time.Now().UTC().Add(time.Hour)); err != nil {
		t.Fatalf("SaveSessionToken: %v", err)
	}
	gotDid, gotSID, err := store.GetSessionToken(ctx, token)
	if err != nil {
		t.Fatalf("GetSessionToken: %v", err)
	}
	if gotDid != did || gotSID != "session-1" {
		t.Errorf("got (%v, %q)", gotDid, gotSID)
	}
}

func TestSessionTokenStoredHashed(t *testing.T) {
	store := newTestStore(t)
	ctx := t.Context()
	if err := store.SaveSessionToken(ctx, "raw-token", testDID(t), "sid", time.Now().UTC().Add(time.Hour)); err != nil {
		t.Fatalf("save: %v", err)
	}
	var n int
	if err := store.db.QueryRow("SELECT COUNT(*) FROM sessions WHERE token_hash = ?", hashToken("raw-token")).Scan(&n); err != nil {
		t.Fatalf("count by hash: %v", err)
	}
	if n != 1 {
		t.Fatalf("row not found by hash (count=%d)", n)
	}
	var raw int
	if err := store.db.QueryRow("SELECT COUNT(*) FROM sessions WHERE token_hash = ?", "raw-token").Scan(&raw); err != nil {
		t.Fatalf("count by raw: %v", err)
	}
	if raw != 0 {
		t.Fatal("raw token stored in the DB")
	}
}

func TestGetSessionTokenExpired(t *testing.T) {
	store := newTestStore(t)
	ctx := t.Context()
	if err := store.SaveSessionToken(ctx, "expired-token", testDID(t), "sid", time.Now().UTC().Add(-time.Hour)); err != nil {
		t.Fatalf("save: %v", err)
	}
	if _, _, err := store.GetSessionToken(ctx, "expired-token"); !errors.Is(err, sql.ErrNoRows) {
		t.Fatalf("expired token: got %v, want sql.ErrNoRows", err)
	}
}

func TestTouchAndDeleteSessionToken(t *testing.T) {
	store := newTestStore(t)
	ctx := t.Context()
	did := testDID(t)
	if err := store.SaveSessionToken(ctx, "tok", did, "sid", time.Now().UTC().Add(time.Minute)); err != nil {
		t.Fatalf("save: %v", err)
	}
	if err := store.TouchSessionToken(ctx, "tok", time.Now().UTC().Add(time.Hour)); err != nil {
		t.Fatalf("touch: %v", err)
	}
	if _, _, err := store.GetSessionToken(ctx, "tok"); err != nil {
		t.Fatalf("get after touch: %v", err)
	}
	if err := store.DeleteSessionToken(ctx, "tok"); err != nil {
		t.Fatalf("delete: %v", err)
	}
	if _, _, err := store.GetSessionToken(ctx, "tok"); !errors.Is(err, sql.ErrNoRows) {
		t.Fatalf("get after delete: %v", err)
	}
}

Add "database/sql", "errors", and "time" to the test file imports.

  • [ ] Step 2: Run test to verify it fails

Run: go test ./internal/authbroker/ -run 'TestSaveAndGetSessionToken|TestSessionTokenStoredHashed|TestGetSessionTokenExpired|TestTouchAndDeleteSessionToken' Expected: FAIL (SaveSessionToken undefined)

  • [ ] Step 3: Write minimal implementation

In internal/authbroker/store.go, add the table to the migrate DDL (inside the existing const ddl string, after the oauth_request trigger block):

CREATE TABLE IF NOT EXISTS sessions (
	token_hash TEXT PRIMARY KEY,
	did TEXT NOT NULL,
	session_id TEXT NOT NULL,
	created_at TIMESTAMP NOT NULL,
	expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_expires_idx ON sessions(expires_at);
CREATE TRIGGER IF NOT EXISTS delete_expired_sessions AFTER INSERT ON sessions
BEGIN
	DELETE FROM sessions WHERE expires_at < datetime('now');
END;

Then append these methods to store.go (after DeleteAuthRequestInfo, before the compile-time check):

// SaveSessionToken persists the sha256(token) → (did, sessionID) mapping. The
// raw token is never stored (see hashToken); a DB leak therefore yields no
// reusable session secrets.
func (s *Store) SaveSessionToken(ctx context.Context, token string, did syntax.DID, sessionID string, expiresAt time.Time) error {
	if _, err := s.db.ExecContext(ctx, `
INSERT INTO sessions (token_hash, did, session_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`,
		hashToken(token), did.String(), sessionID,
		time.Now().UTC().Format(sqliteTimefmt), expiresAt.UTC().Format(sqliteTimefmt)); err != nil {
		return fmt.Errorf("authbroker: save session token: %w", err)
	}
	return nil
}

// GetSessionToken resolves a raw token to its (did, sessionID), rejecting
// tokens that are missing or expired.
func (s *Store) GetSessionToken(ctx context.Context, token string) (syntax.DID, string, error) {
	var did, sessionID string
	if err := s.db.QueryRowContext(ctx,
		"SELECT did, session_id FROM sessions WHERE token_hash = ? AND expires_at > ?",
		hashToken(token), time.Now().UTC().Format(sqliteTimefmt)).Scan(&did, &sessionID); err != nil {
		return "", "", fmt.Errorf("authbroker: get session token: %w", err)
	}
	parsed, err := syntax.ParseDID(did)
	if err != nil {
		return "", "", fmt.Errorf("authbroker: parse session did: %w", err)
	}
	return parsed, sessionID, nil
}

// TouchSessionToken extends a session's absolute expiry (sliding refresh).
func (s *Store) TouchSessionToken(ctx context.Context, token string, expiresAt time.Time) error {
	if _, err := s.db.ExecContext(ctx,
		"UPDATE sessions SET expires_at = ? WHERE token_hash = ? AND expires_at > ?",
		expiresAt.UTC().Format(sqliteTimefmt), hashToken(token), time.Now().UTC().Format(sqliteTimefmt)); err != nil {
		return fmt.Errorf("authbroker: touch session token: %w", err)
	}
	return nil
}

// DeleteSessionToken removes the mapping for token (logout).
func (s *Store) DeleteSessionToken(ctx context.Context, token string) error {
	if _, err := s.db.ExecContext(ctx, "DELETE FROM sessions WHERE token_hash = ?", hashToken(token)); err != nil {
		return fmt.Errorf("authbroker: delete session token: %w", err)
	}
	return nil
}
  • [ ] Step 4: Run test to verify it passes

Run: go test ./internal/authbroker/ -run 'TestSaveAndGetSessionToken|TestSessionTokenStoredHashed|TestGetSessionTokenExpired|TestTouchAndDeleteSessionToken' Expected: PASS

  • [ ] Step 5: Commit
git add internal/authbroker/store.go internal/authbroker/session_test.go
git commit -m "feat(authbroker): sessions table keyed by sha256(token)"

Task 3: Wire the session store into the Broker

Files: - Modify: internal/authbroker/authbroker.go

  • [ ] Step 1: Add the field and type-assert

In authbroker.go, change the Broker struct:

type Broker struct {
	app      *oauth.ClientApp
	cfg      Config
	sessions *Store // optional: browser-session mapping table (set when store is a *Store)
}

In NewBroker, replace the final return &Broker{ app: ..., cfg: ... } block:

	b := &Broker{
		app: oauth.NewClientApp(clientCfg, store),
		cfg: cfg,
	}
	if s, ok := store.(*Store); ok {
		b.sessions = s
	}
	return b, nil
  • [ ] Step 2: Build & test

Run: go build ./... && go test ./internal/authbroker/ Expected: PASS (existing TestNewBroker* tests still pass)

  • [ ] Step 3: Commit
git add internal/authbroker/authbroker.go
git commit -m "feat(authbroker): expose browser-session store on Broker"

Task 4: Opaque cookie in the OAuth callback

Files: - Modify: internal/authbroker/handler.go

  • [ ] Step 1: Replace the cookie-setting block

In the GET /oauth/callback handler, replace the current http.SetCookie(...) + comment with:

		token, err := b.IssueSession(r.Context(), session.AccountDID, session.SessionID)
		if err != nil {
			http.Error(w, "callback: "+err.Error(), http.StatusInternalServerError)
			return
		}
		SetSessionCookie(w, token)
		http.Redirect(w, r, "/", http.StatusFound)
  • [ ] Step 2: Build & test

Run: go build ./... && go test ./internal/authbroker/ Expected: PASS

  • [ ] Step 3: Commit
git add internal/authbroker/handler.go
git commit -m "feat(authbroker): set opaque session cookie on callback"

Task 5: Token lookup in SessionAuth + logout handler

Files: - Modify: internal/appview/ui/session.go

  • [ ] Step 1: Write the failing test

Create internal/appview/ui/session_test.go:

package ui

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

	"github.com/bluesky-social/indigo/atproto/atcrypto"
	"github.com/bluesky-social/indigo/atproto/auth/oauth"
	"github.com/bluesky-social/indigo/atproto/syntax"

	"git.kilimanjaro.io/sovrn/internal/appview"
	"git.kilimanjaro.io/sovrn/internal/authbroker"
)

func newTestBroker(t *testing.T) (*authbroker.Broker, *authbroker.Store) {
	t.Helper()
	store, err := authbroker.Open(":memory:")
	if err != nil {
		t.Fatalf("authbroker.Open: %v", err)
	}
	t.Cleanup(func() { _ = store.DB().Close() })
	key, err := atcrypto.GeneratePrivateKeyP256()
	if err != nil {
		t.Fatalf("generate key: %v", err)
	}
	broker, err := authbroker.NewBroker(authbroker.Config{
		ClientID:    "https://auth.sovrn.example/client-metadata.json",
		CallbackURL: "https://auth.sovrn.example/callback",
		Scopes:      []string{"atproto"},
		KeyID:       "k-1",
		PrivateKey:  key,
	}, store)
	if err != nil {
		t.Fatalf("NewBroker: %v", err)
	}
	return broker, store
}

func seedSession(t *testing.T, broker *authbroker.Broker, store *authbroker.Store) (syntax.DID, string) {
	t.Helper()
	did, err := syntax.ParseDID("did:plc:0123456789abcdef01234567")
	if err != nil {
		t.Fatalf("parse did: %v", err)
	}
	if err := store.SaveSession(t.Context(), oauth.ClientSessionData{AccountDID: did, SessionID: "sid-1"}); err != nil {
		t.Fatalf("save indigo session: %v", err)
	}
	token, err := broker.IssueSession(t.Context(), did, "sid-1")
	if err != nil {
		t.Fatalf("IssueSession: %v", err)
	}
	return did, token
}

func testRequestWithCookie(token string) *http.Request {
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	if token != "" {
		req.AddCookie(&http.Cookie{Name: authbroker.SessionCookieName, Value: token})
	}
	return req
}

func TestSessionAuthValidOpaqueToken(t *testing.T) {
	broker, store := newTestBroker(t)
	did, token := seedSession(t, broker, store)

	var got string
	h := SessionAuth{Broker: broker}.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		got = appview.DIDFromContext(r.Context())
	}))
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, testRequestWithCookie(token))

	if got != did.String() {
		t.Fatalf("DIDFromContext = %q, want %q", got, did.String())
	}
}

func TestSessionAuthForgedTokenRedirects(t *testing.T) {
	broker, _ := newTestBroker(t)
	h := SessionAuth{Broker: broker}.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		t.Error("handler ran despite forged token")
	}))
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, testRequestWithCookie("not-a-real-token"))

	if rec.Code != http.StatusSeeOther {
		t.Fatalf("status = %d, want 303", rec.Code)
	}
	if loc := rec.Header().Get("Location"); loc != "/login" {
		t.Fatalf("Location = %q, want /login", loc)
	}
}

func TestSessionAuthExpiredTokenRedirects(t *testing.T) {
	broker, store := newTestBroker(t)
	did, _ := seedSession(t, broker, store)
	token, err := broker.IssueSession(t.Context(), did, "sid-1")
	if err != nil {
		t.Fatalf("IssueSession: %v", err)
	}
	// Backdate the row so it is expired.
	if _, err := store.DB().Exec("UPDATE sessions SET expires_at = ? WHERE token_hash = ?",
		time.Now().UTC().Add(-time.Hour).Format("2006-01-02 15:04:05"), "PLACEHOLDER"); err != nil {
		t.Fatalf("backdate: %v", err)
	}
	// (the hash placeholder is replaced by direct hash in Task execution; see note)
	_ = token

	h := SessionAuth{Broker: broker}.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		t.Error("handler ran despite expired token")
	}))
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, testRequestWithCookie(token))
	if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login" {
		t.Fatalf("expired token: code=%d loc=%q, want 303 /login", rec.Code, rec.Header().Get("Location"))
	}
}

Note (expired-token test): the DB token_hash must equal sha256(token). Since hashToken is unexported in authbroker, compute it in the test with sha256.Sum256([]byte(token)) + hex.EncodeToString. Add "crypto/sha256", "encoding/hex" imports and replace the PLACEHOLDER Exec with:

sum := sha256.Sum256([]byte(token))
h := hex.EncodeToString(sum[:])
if _, err := store.DB().Exec("UPDATE sessions SET expires_at = ? WHERE token_hash = ?",
	time.Now().UTC().Add(-time.Hour).Format("2006-01-02 15:04:05"), h); err != nil {
	t.Fatalf("backdate: %v", err)
}
  • [ ] Step 2: Run test to verify it fails

Run: go test ./internal/appview/ui/ -run TestSessionAuth -v Expected: FAIL (current authenticate still parses DID|sessionID)

  • [ ] Step 3: Rewrite session.go

Replace the whole internal/appview/ui/session.go with:

package ui

import (
	"net/http"
	"time"

	"git.kilimanjaro.io/sovrn/internal/appview"
	"git.kilimanjaro.io/sovrn/internal/authbroker"
)

// SessionAuth authenticates the app-view UI via the opaque browser-session
// cookie (docs/02 §4): it resolves the token to (did, sessionID), validates
// the session against the authbroker (resume), and injects the DID into the
// request context. In development (DevDID set) a request without a valid
// session authenticates as the fixed DID, mirroring the XRPC auth fallback.
// Otherwise it redirects to /login.
type SessionAuth struct {
	Broker *authbroker.Broker
	DevDID string
}

// Middleware returns the handler that populates the DID context value.
func (a SessionAuth) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		did, ok := a.authenticate(r)
		if !ok {
			http.Redirect(w, r, "/login", http.StatusSeeOther)
			return
		}
		next.ServeHTTP(w, r.WithContext(appview.WithDID(r.Context(), did)))
	})
}

// LogoutHandler clears the browser session: it revokes the underlying OAuth
// session, deletes the token mapping, and clears the cookie.
func (a SessionAuth) LogoutHandler() http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if a.Broker != nil {
			if cookie, err := r.Cookie(authbroker.SessionCookieName); err == nil && cookie.Value != "" {
				if did, sessionID, err := a.Broker.GetSession(r.Context(), cookie.Value); err == nil {
					_ = a.Broker.Logout(r.Context(), did, sessionID)
				}
				_ = a.Broker.DeleteSession(r.Context(), cookie.Value)
			}
		}
		authbroker.ClearSessionCookie(w)
		http.Redirect(w, r, "/login", http.StatusSeeOther)
	})
}

// authenticate returns the DID of a valid session (or the dev fallback), and
// whether one was found.
func (a SessionAuth) authenticate(r *http.Request) (string, bool) {
	if cookie, err := r.Cookie(authbroker.SessionCookieName); err == nil && cookie.Value != "" && a.Broker != nil {
		if did, sessionID, err := a.Broker.GetSession(r.Context(), cookie.Value); err == nil {
			if _, err := a.Broker.ResumeSession(r.Context(), did, sessionID); err == nil {
				_ = a.Broker.TouchSession(r.Context(), cookie.Value)
				return did.String(), true
			}
		}
	}
	if a.DevDID != "" {
		return a.DevDID, true
	}
	return "", false
}
  • [ ] Step 4: Run test to verify it passes

Run: go test ./internal/appview/ui/ -run TestSessionAuth -v Expected: PASS

  • [ ] Step 5: Commit
git add internal/appview/ui/session.go internal/appview/ui/session_test.go
git commit -m "feat(ui): opaque-token session auth + logout handler"

Task 6: Mount /logout and add a sign-out affordance

Files: - Modify: router.go - Modify: internal/appview/ui/home.templ

  • [ ] Step 1: Mount the route

In router.go, after top.Handle("/", sessionAuth.Middleware(ui.New(uiDeps))), add:

	top.Handle("POST /logout", sessionAuth.LogoutHandler())
  • [ ] Step 2: Add sign-out button

In internal/appview/ui/home.templ, wrap the header line in a flex row with a sign-out form:

		<div class="flex items-center justify-between">
			<div>
				<h1 class="text-2xl font-bold tracking-tight">Your domains</h1>
				<p class="mt-1 text-sm text-slate-600">Signed in as <span class="font-mono">{ d.DID }</span></p>
			</div>
			<form action="/logout" method="post">
				<button type="submit" class="text-sm font-medium text-blue-600 hover:text-blue-700">Sign out</button>
			</form>
		</div>

Regenerate the templ output:

Run: cd internal/appview/ui && go run github.com/a-h/templ/cmd/templ generate

  • [ ] Step 3: Build & test

Run: go build ./... && go test ./internal/appview/ui/ ./... Expected: PASS

  • [ ] Step 4: Commit
git add router.go internal/appview/ui/home.templ internal/appview/ui/home_templ.go
git commit -m "feat(ui): add /logout route and sign-out button"

Task 7: Restart-persistence test

Files: - Modify: internal/authbroker/session_test.go

  • [ ] Step 1: Write the test
func TestSessionTokenPersistsAcrossReopen(t *testing.T) {
	path := filepath.Join(t.TempDir(), "oauth.db")
	store, err := Open(path)
	if err != nil {
		t.Fatalf("Open: %v", err)
	}
	did := testDID(t)
	if err := store.SaveSessionToken(t.Context(), "persist-token", did, "sid", time.Now().UTC().Add(time.Hour)); err != nil {
		t.Fatalf("save: %v", err)
	}
	if err := store.DB().Close(); err != nil {
		t.Fatalf("close: %v", err)
	}

	store2, err := Open(path)
	if err != nil {
		t.Fatalf("reopen: %v", err)
	}
	defer store2.DB().Close()
	gotDid, gotSID, err := store2.GetSessionToken(t.Context(), "persist-token")
	if err != nil {
		t.Fatalf("get after reopen: %v", err)
	}
	if gotDid != did || gotSID != "sid" {
		t.Fatalf("got (%v, %q)", gotDid, gotSID)
	}
}

Add "path/filepath" to test imports.

  • [ ] Step 2: Run it

Run: go test ./internal/authbroker/ -run TestSessionTokenPersistsAcrossReopen Expected: PASS

  • [ ] Step 3: Commit
git add internal/authbroker/session_test.go
git commit -m "test(authbroker): session token survives process restart"

Task 8: Full verification

  • [ ] Step 1: Run the whole suite

Run: go test ./... Expected: PASS

  • [ ] Step 2: Lint/vet

Run: go vet ./... Expected: clean

  • [ ] Step 3: Report + close after user review

Post a summary comment on bug 40e284e; do NOT close until the user verifies.

agent 480ee32 Sep 10

Implementation complete

Opaque session cookie implemented. The sovrn_session cookie is now a 256-bit CSPRNG base64url token; the DID and indigo sessionID never appear in it.

Security note on the mapping key

Per review feedback, the sessions table is keyed by sha256(token), never the raw token — a DB leak yields no reusable secrets (hex digest, not a bearer token). Lookup is always sha256(cookie_token) -> (did, session_id).

Changes

  • internal/authbroker/session.go (new): newSessionToken (256-bit CSPRNG, base64url), hashToken (sha256 hex), SessionCookieName, SetSessionCookie/ClearSessionCookie (HttpOnly; Secure; SameSite=Lax; Path=/), and Broker methods IssueSession/GetSession/TouchSession/DeleteSession.
  • internal/authbroker/store.go: added sessions(token_hash PK, did, session_id, created_at, expires_at) + expiry index + GC trigger, and SaveSessionToken/GetSessionToken/TouchSessionToken/DeleteSessionToken (all hash internally).
  • internal/authbroker/authbroker.go: Broker.sessions wired via *Store type-assert in NewBroker.
  • internal/authbroker/handler.go: callback now mints a token and sets the opaque cookie instead of DID|sessionID.
  • internal/appview/ui/session.go: authenticate resolves token -> (did, sessionID) -> ResumeSession -> TouchSession (sliding refresh); added LogoutHandler (revoke + delete row + clear cookie).
  • router.go: mounts POST /logout.
  • internal/appview/ui/home.templ: sign-out button.

Acceptance

  • Cookie contains no DID/session data — TestNewSessionTokenOpaque asserts no did:/| and 32-byte decode across 200 mints; TestSessionTokenStoredHashed asserts only the digest is stored.
  • Forged/expired token redirects to /login — TestSessionAuthForgedTokenRedirects, TestSessionAuthExpiredTokenRedirects, TestGetSessionTokenExpired.
  • Survives restart — TestSessionTokenPersistsAcrossReopen reopens the DB file.

Verification

SOVRN_INTEGRATION=0 go test ./... passes; go vet ./... clean; gofmt clean. (Integration suite needs the live Stalwart harness; not run.)

Note

Secure is set unconditionally per OWASP, so over plain HTTP (dev) the browser won’t persist the cookie and auth falls back to DevDID. Production runs HTTPS.

Not closing until you’ve reviewed.