Auth: opaque session cookie (never a naked DID)
closedGoal
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
Opaque Session Cookie Implementation Plan
Goal: Replace the
DID|sessionIDbrowser cookie with an opaque 256-bit token backed by a server-side SQLite mapping table whose key issha256(token).Architecture: The authbroker
Store(same SQLite DB,oauth.db) gains asessionstable keyed by the SHA-256 hash of the opaque token (never the raw token). TheBrokermints/validates tokens; the callback sets the cookie;ui.SessionAuthresolves token → (did, sessionID) →ResumeSession, with sliding expiry; a new/logoutendpoint deletes the row and clears the cookie.Tech Stack: Go stdlib (
crypto/rand,crypto/sha256,encoding/base64,encoding/hex),database/sql+ mattn/go-sqlite3, indigooauth, a-h/templ.Task 1: Token minting + SHA-256 hashing helpers (authbroker)
Files: - Create:
internal/authbroker/session.goCreate
internal/authbroker/session_test.go:Run:
go test ./internal/authbroker/ -run 'TestNewSessionTokenOpaque|TestHashTokenDeterministic'Expected: FAIL (undefined: newSessionToken,undefined: hashToken)Create
internal/authbroker/session.go:Run:
go test ./internal/authbroker/ -run 'TestNewSessionTokenOpaque|TestHashTokenDeterministic'Expected: PASSTask 2: sessions table + Store methods (hashed key)
Files: - Modify:
internal/authbroker/store.goAppend to
internal/authbroker/session_test.go:Add
"database/sql","errors", and"time"to the test file imports.Run:
go test ./internal/authbroker/ -run 'TestSaveAndGetSessionToken|TestSessionTokenStoredHashed|TestGetSessionTokenExpired|TestTouchAndDeleteSessionToken'Expected: FAIL (SaveSessionToken undefined)In
internal/authbroker/store.go, add the table to themigrateDDL (inside the existingconst ddlstring, after theoauth_requesttrigger block):Then append these methods to
store.go(afterDeleteAuthRequestInfo, before the compile-time check):Run:
go test ./internal/authbroker/ -run 'TestSaveAndGetSessionToken|TestSessionTokenStoredHashed|TestGetSessionTokenExpired|TestTouchAndDeleteSessionToken'Expected: PASSTask 3: Wire the session store into the Broker
Files: - Modify:
internal/authbroker/authbroker.goIn
authbroker.go, change theBrokerstruct:In
NewBroker, replace the finalreturn &Broker{ app: ..., cfg: ... }block:Run:
go build ./... && go test ./internal/authbroker/Expected: PASS (existingTestNewBroker*tests still pass)Task 4: Opaque cookie in the OAuth callback
Files: - Modify:
internal/authbroker/handler.goIn the
GET /oauth/callbackhandler, replace the currenthttp.SetCookie(...)+ comment with:Run:
go build ./... && go test ./internal/authbroker/Expected: PASSTask 5: Token lookup in SessionAuth + logout handler
Files: - Modify:
internal/appview/ui/session.goCreate
internal/appview/ui/session_test.go:Note (expired-token test): the DB
token_hashmust equalsha256(token). SincehashTokenis unexported inauthbroker, compute it in the test withsha256.Sum256([]byte(token))+hex.EncodeToString. Add"crypto/sha256","encoding/hex"imports and replace thePLACEHOLDERExec with:Run:
go test ./internal/appview/ui/ -run TestSessionAuth -vExpected: FAIL (currentauthenticatestill parsesDID|sessionID)Replace the whole
internal/appview/ui/session.gowith:Run:
go test ./internal/appview/ui/ -run TestSessionAuth -vExpected: PASSTask 6: Mount /logout and add a sign-out affordance
Files: - Modify:
router.go- Modify:internal/appview/ui/home.templIn
router.go, aftertop.Handle("/", sessionAuth.Middleware(ui.New(uiDeps))), add:In
internal/appview/ui/home.templ, wrap the header line in a flex row with a sign-out form:Regenerate the templ output:
Run:
cd internal/appview/ui && go run github.com/a-h/templ/cmd/templ generateRun:
go build ./... && go test ./internal/appview/ui/ ./...Expected: PASSTask 7: Restart-persistence test
Files: - Modify:
internal/authbroker/session_test.goAdd
"path/filepath"to test imports.Run:
go test ./internal/authbroker/ -run TestSessionTokenPersistsAcrossReopenExpected: PASSTask 8: Full verification
Run:
go test ./...Expected: PASSRun:
go vet ./...Expected: cleanPost a summary comment on bug
40e284e; do NOT close until the user verifies.Implementation complete
Opaque session cookie implemented. The
sovrn_sessioncookie 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
sessionstable is keyed bysha256(token), never the raw token — a DB leak yields no reusable secrets (hex digest, not a bearer token). Lookup is alwayssha256(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 methodsIssueSession/GetSession/TouchSession/DeleteSession.internal/authbroker/store.go: addedsessions(token_hash PK, did, session_id, created_at, expires_at)+ expiry index + GC trigger, andSaveSessionToken/GetSessionToken/TouchSessionToken/DeleteSessionToken(all hash internally).internal/authbroker/authbroker.go:Broker.sessionswired via*Storetype-assert inNewBroker.internal/authbroker/handler.go: callback now mints a token and sets the opaque cookie instead ofDID|sessionID.internal/appview/ui/session.go:authenticateresolves token -> (did, sessionID) ->ResumeSession->TouchSession(sliding refresh); addedLogoutHandler(revoke + delete row + clear cookie).router.go: mountsPOST /logout.internal/appview/ui/home.templ: sign-out button.Acceptance
TestNewSessionTokenOpaqueasserts nodid:/|and 32-byte decode across 200 mints;TestSessionTokenStoredHashedasserts only the digest is stored./login—TestSessionAuthForgedTokenRedirects,TestSessionAuthExpiredTokenRedirects,TestGetSessionTokenExpired.TestSessionTokenPersistsAcrossReopenreopens the DB file.Verification
SOVRN_INTEGRATION=0 go test ./...passes;go vet ./...clean;gofmtclean. (Integration suite needs the live Stalwart harness; not run.)Note
Secureis set unconditionally per OWASP, so over plain HTTP (dev) the browser won’t persist the cookie and auth falls back toDevDID. Production runs HTTPS.Not closing until you’ve reviewed.