Files
at-container-registry/pkg/appview/db/session_store.go
T
Evan JarrettandClaude Opus 5 8cd59a61f1 appview: stop a UI session outliving the OAuth session behind it
Only one oauth_sessions row is kept per account, so signing in again — on a
second device, or simply a second time — replaces it and leaves every earlier
ui_sessions row pointing at an oauth_session_id that no longer exists. Get
checked only expiry, so those still read back as usable.

Found on a live appview: four ui_sessions rows, three orphaned, and requesting
/settings/user with an orphaned cookie returned 200 with the account's handle
rendered throughout, where an anonymous request gets a 302. The browser looks
signed in while the credential behind it is gone, so every PDS-backed action
fails against a UI insisting the session is fine. It now fails closed and sends
the user back through login.

Get also never checked ownership. oauth_sessions is unique on
(account_did, session_id), so the existence check is scoped by both; matching
session_id alone would let one account's live OAuth session validate another
account's dangling reference. That has its own test.

An empty oauth_session_id stays valid, since Create makes sessions that never
had one, and a test pins that so the check cannot start rejecting them.

TestSessionStore_CreateWithOAuth referenced an OAuth session it never inserted,
which is an orphan by definition, so it now creates the row. Its intent was
that CreateWithOAuth persists the ID; it relied on the orphan behaviour only
incidentally. Its not-found branch used t.Error and then dereferenced the nil
session, so that is now t.Fatal.

Pre-existing at efabb677 rather than introduced by this range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 16:34:25 -05:00

253 lines
6.8 KiB
Go

package db
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"fmt"
"log/slog"
"net/http"
"time"
)
// Session represents a user session
// Compatible with pkg/appview/session.Session
type Session struct {
ID string
DID string
Handle string
PDSEndpoint string
OAuthSessionID string // Links to oauth_sessions.session_id
ExpiresAt time.Time
}
// SessionStoreInterface defines the session storage interface
// Both db.SessionStore and session.Store implement this
type SessionStoreInterface interface {
Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error)
CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error)
Get(id string) (*Session, bool)
Delete(id string)
Cleanup()
}
// SessionStore manages user sessions with SQLite persistence
type SessionStore struct {
db *sql.DB
}
// NewSessionStore creates a new SQLite-backed session store
func NewSessionStore(db *sql.DB) *SessionStore {
return &SessionStore{db: db}
}
// Create creates a new session and returns the session ID
func (s *SessionStore) Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) {
return s.CreateWithOAuth(did, handle, pdsEndpoint, "", duration)
}
// CreateWithOAuth creates a new session with OAuth sessionID and returns the session ID
func (s *SessionStore) CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error) {
// Generate random session ID
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("failed to generate session ID: %w", err)
}
sessionID := base64.URLEncoding.EncodeToString(b)
expiresAt := time.Now().Add(duration)
_, err := s.db.Exec(`
INSERT INTO ui_sessions (id, did, handle, pds_endpoint, oauth_session_id, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
`, sessionID, did, handle, pdsEndpoint, oauthSessionID, expiresAt)
if err != nil {
return "", fmt.Errorf("failed to create session: %w", err)
}
return sessionID, nil
}
// Get retrieves a session by ID.
//
// A ui_sessions row outlives the OAuth session it references. Only one
// oauth_sessions row is kept per account, so signing in again — on a second
// device, or simply a second time — replaces it and leaves every earlier row
// pointing at an oauth_session_id that no longer exists. Those must not read
// back as usable: the browser would render fully signed in while the credential
// behind it is gone, so every PDS-backed action fails against a UI insisting
// the session is fine. Failing closed here sends them back through login.
//
// An empty oauth_session_id is legitimate and stays usable — Create makes
// sessions that never had one.
//
// The lookup is scoped by account_did as well as session_id because
// oauth_sessions is unique on that pair; matching session_id alone would let
// one account's live OAuth session validate another account's dangling
// reference.
func (s *SessionStore) Get(id string) (*Session, bool) {
var sess Session
var oauthLive bool
err := s.db.QueryRow(`
SELECT u.id, u.did, u.handle, u.pds_endpoint, u.oauth_session_id, u.expires_at,
(u.oauth_session_id IS NULL OR u.oauth_session_id = ''
OR EXISTS (
SELECT 1 FROM oauth_sessions o
WHERE o.account_did = u.did AND o.session_id = u.oauth_session_id
)) AS oauth_live
FROM ui_sessions u
WHERE u.id = ?
`, id).Scan(&sess.ID, &sess.DID, &sess.Handle, &sess.PDSEndpoint, &sess.OAuthSessionID,
&sess.ExpiresAt, &oauthLive)
if err == sql.ErrNoRows {
return nil, false
}
if err != nil {
slog.Warn("Failed to query session", "error", err)
return nil, false
}
// Check if expired
if time.Now().After(sess.ExpiresAt) {
return nil, false
}
if !oauthLive {
slog.Info("Rejecting UI session whose OAuth session is gone",
"did", sess.DID,
"oauthSessionID", sess.OAuthSessionID)
return nil, false
}
return &sess, true
}
// Extend extends a session's expiration time
func (s *SessionStore) Extend(id string, duration time.Duration) error {
expiresAt := time.Now().Add(duration)
result, err := s.db.Exec(`
UPDATE ui_sessions
SET expires_at = ?
WHERE id = ?
`, expiresAt, id)
if err != nil {
return fmt.Errorf("failed to extend session: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("session not found: %s", id)
}
return nil
}
// Delete removes a session
func (s *SessionStore) Delete(id string) {
_, err := s.db.Exec(`
DELETE FROM ui_sessions WHERE id = ?
`, id)
if err != nil {
slog.Warn("Failed to delete session", "error", err)
}
}
// DeleteByDID removes all sessions for a given DID
// This is useful when OAuth refresh fails and we need to force re-authentication
func (s *SessionStore) DeleteByDID(did string) {
result, err := s.db.Exec(`
DELETE FROM ui_sessions WHERE did = ?
`, did)
if err != nil {
slog.Warn("Failed to delete sessions for DID", "did", did, "error", err)
return
}
deleted, _ := result.RowsAffected()
if deleted > 0 {
slog.Info("Deleted UI sessions for DID due to OAuth failure", "count", deleted, "did", did)
}
}
// Cleanup removes expired sessions
func (s *SessionStore) Cleanup() {
result, err := s.db.Exec(`
DELETE FROM ui_sessions
WHERE expires_at < datetime('now')
`)
if err != nil {
slog.Warn("Failed to cleanup sessions", "error", err)
return
}
deleted, _ := result.RowsAffected()
if deleted > 0 {
slog.Info("Cleaned up expired UI sessions", "count", deleted)
}
}
// CleanupContext is a context-aware version of Cleanup for background workers
func (s *SessionStore) CleanupContext(ctx context.Context) error {
result, err := s.db.ExecContext(ctx, `
DELETE FROM ui_sessions
WHERE expires_at < datetime('now')
`)
if err != nil {
return fmt.Errorf("failed to cleanup sessions: %w", err)
}
deleted, _ := result.RowsAffected()
if deleted > 0 {
slog.Info("Cleaned up expired UI sessions", "count", deleted)
}
return nil
}
// Cookie helper functions (compatible with pkg/appview/session package)
// SetCookie sets the session cookie
func SetCookie(w http.ResponseWriter, sessionID string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: "atcr_session",
Value: sessionID,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
// ClearCookie clears the session cookie
func ClearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: "atcr_session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
// GetSessionID gets session ID from cookie
func GetSessionID(r *http.Request) (string, bool) {
cookie, err := r.Cookie("atcr_session")
if err != nil {
return "", false
}
return cookie.Value, true
}