diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 61fdca0..b5b2c1c 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -88,6 +88,7 @@ jobs: env: REDIS_ADDR: localhost:56379 + AUTH_TEST_REDIS_ADDR: localhost:56379 RABBITMQ_STREAM_URL: rabbitmq-stream://guest:guest@localhost:55552 RABBITMQ_AMQP_URL: amqp://guest:guest@localhost:55672/ POSTGRES_DSN: postgres://aether:aether_test@localhost:5432/aether?sslmode=disable diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c78667..dab815f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Aet --- +## [Unreleased] + +### Added + +- **[AUTHPROXY] Redis/Valkey browser-session inventory and revocation.** The optional `login.SessionManager` API lists session metadata and supports individual and per-user bulk revocation across replicas. Management IDs cannot authenticate as browser cookies. Session creation and legacy migration prune expired index entries; logout and individual revocation restore a finite index lifetime after its last nonexpiring session is removed. + +### Upgrade notes + +- **[AUTHPROXY] Coordinated upgrade required for Redis browser sessions**, including deployments that do not expose session management. Drain and stop every old replica before any new replica serves requests; mixed versions cannot consistently read sessions or enforce revocation. Keep the same Redis primary, DB and prefix to migrate valid legacy sessions on use. **Rollback, stale-backup restore, or recovery from acknowledged-write loss requires a fresh, never-used session prefix on every replica and new sign-ins.** Do not reset generation counters while retaining session records. See the [browser-session upgrade and rollback guide](server/docs/auth-proxy-sessions.md#upgrade-and-rollback). JWT mode is unaffected. + +--- + ## [0.2.2] - Unreleased Work landed since the **v0.2.1** release (2026-05-22); not yet tagged. @@ -242,7 +254,7 @@ Initial public OSS release of the Aether gateway, SDKs (Go, Python, TypeScript), --- -[Unreleased]: https://github.com/scitrera/aether/compare/v0.1.60...HEAD +[Unreleased]: https://github.com/scitrera/aether/compare/v0.2.3...HEAD [0.1.60]: https://github.com/scitrera/aether/compare/v0.1.59...v0.1.60 [0.1.59]: https://github.com/scitrera/aether/compare/v0.1.58...v0.1.59 [0.1.58]: https://github.com/scitrera/aether/releases/tag/v0.1.58 diff --git a/docs/environment.md b/docs/environment.md index 235ecd4..6e939d3 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -275,6 +275,13 @@ disabling JWT signature verification during development. ### Auth Proxy — Browser login flow +**Redis browser-session upgrade:** drain and stop all older replicas before the +new session-store version serves requests, even if session management is not +exposed. Keep the same primary, DB and prefix to preserve valid sessions during +upgrade. Rollback or recovery from stale/lost revocation state requires a fresh, +never-used `AUTH_PROXY_SESSION_REDIS_PREFIX` on every replica and new sign-ins. +See [upgrade and rollback steps](../server/docs/auth-proxy-sessions.md#upgrade-and-rollback). + The login flow is **enabled iff** `AUTH_PROXY_LOGIN_PROVIDERS` is non-empty. Each provider name maps to a set of per-provider variables (the provider name is upper-cased and `-` is replaced with `_` when forming the key). diff --git a/server/docs/auth-proxy-sessions.md b/server/docs/auth-proxy-sessions.md new file mode 100644 index 0000000..3e21947 --- /dev/null +++ b/server/docs/auth-proxy-sessions.md @@ -0,0 +1,77 @@ +# Browser sessions + +`SessionStore` handles cookie issuance, lookup and logout. The Redis/Valkey store +also implements the optional `SessionManager` interface for per-user listing, +single revocation and bulk revocation. JWT stores intentionally do not implement +that interface. Applications must expose management only behind their own +administrative authentication, authorization, CSRF protection and audit trail. + +`ListSessions(ctx, subject, limit, offset)` returns only management ID, provider, +creation and expiry. The subject is normalized email when available, otherwise +UserID. Email normalization trims/lowercases and converts IDNA domains. Management +IDs are SHA-256 digests of 256-bit random cookies, never bearer credentials. +The store does not track presence or last activity. Pagination (limit 1–200, +offset 0–1000000) is a live view ordered by expiry then management ID. + +`RevokeSession` is scoped to the subject and idempotent. `RevokeAllSessions` +atomically advances the subject's generation; later logins remain possible. +Every lookup verifies that generation. Session creation and indexing use a Lua +script to share an atomic boundary with bulk revocation. Redis failure never +returns an authenticated session or a successful empty inventory. + +## Upgrade and rollback + +**This storage change applies to every Redis/Valkey browser-session deployment, +even when no application exposes `SessionManager`.** JWT mode is unaffected. +Older replicas cannot read the new session records or enforce their revocation +generations. Mixing versions can cause intermittent authentication failures and +allow legacy sessions to authenticate after bulk revocation. + +For an upgrade that preserves valid browser sessions: + +1. Drain and stop all replicas running the older session store, including + applications that embed this package. Do this before the new version serves + requests; do not use a rolling deployment that serves both versions together. +2. Deploy the new version to every replica, using the same Redis/Valkey primary, + logical DB and `AUTH_PROXY_SESSION_REDIS_PREFIX` (or library constructor + prefix) as before. Restore traffic only after all serving replicas are upgraded. +3. Verify login, logout, and any application's session-management flow. Legacy + sessions under `` migrate atomically when used and then appear + in the index. The inventory does not include unused legacy sessions, but bulk + revocation invalidates those sessions too. + +For rollback, restoring a stale backup, or recovery from acknowledged-write loss: + +1. Drain and stop all serving replicas. +2. Choose a fresh, never-used session prefix and configure it on every replica + before restoring traffic. For the standalone auth-proxy, set + `AUTH_PROXY_SESSION_REDIS_PREFIX`, for example `auth-session:recovery-20260911:`. + Library users must pass the new prefix to `NewRedisOpaqueSessionStore`. +3. Require all users to sign in again. Keep the old session records out of the new + prefix, and do not later reuse the old prefix or reset generation counters + while its session records remain. Restoring old records or losing revocation + state can otherwise make revoked sessions valid again. + +Previously accepted requests and downstream/IdP sessions are outside this store's +revocation boundary. + +## Storage lifetime + +Finite session records expire at their session lifetime. Creation and legacy +migration prune expired index entries, so retention does not depend on anyone +calling `ListSessions`. Logout and individual revocation set the index lifetime +to the latest remaining finite expiry; an empty index is removed. An index stays +nonexpiring while it contains a nonexpiring session. + +A user generation counter persists after bulk revocation. Bulk-revoked records +expire naturally; explicitly nonexpiring records (zero `ExpiresAt` in library +calls) remain stored. Keep generation state for as long as records under that +prefix can remain. Use persistence, `noeviction` and sufficient capacity. All +replicas must use the same primary, DB and prefix. The implementation targets a +single Redis/Valkey endpoint, not Redis Cluster. + +## Testing + +Tests use miniredis by default. Set `AUTH_TEST_REDIS_ADDR` to exercise the same +suite against real Redis/Valkey; each test owns a unique key prefix. Run +`go test -race ./pkg/authproxy/login ./pkg/authproxy ./internal/auth` from `server`. diff --git a/server/go.mod b/server/go.mod index 1c1f612..a48c69b 100644 --- a/server/go.mod +++ b/server/go.mod @@ -46,8 +46,9 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 golang.org/x/crypto v0.51.0 + golang.org/x/net v0.55.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/sync v0.20.0 + golang.org/x/sync v0.21.0 golang.org/x/time v0.15.0 google.golang.org/grpc v1.82.1 google.golang.org/protobuf v1.36.11 @@ -115,9 +116,8 @@ require ( go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/text v0.39.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect modernc.org/libc v1.70.0 // indirect diff --git a/server/go.sum b/server/go.sum index b05942c..2212480 100644 --- a/server/go.sum +++ b/server/go.sum @@ -240,26 +240,26 @@ golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo= diff --git a/server/pkg/authproxy/login/session_management.go b/server/pkg/authproxy/login/session_management.go new file mode 100644 index 0000000..7876cfc --- /dev/null +++ b/server/pkg/authproxy/login/session_management.go @@ -0,0 +1,333 @@ +package login + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/redis/go-redis/v9" + "golang.org/x/net/idna" +) + +// SessionInfo deliberately excludes credentials and provider claims. ID is a +// management handle, never a cookie value. A session is valid, not necessarily +// online. ExpiresAt is zero for callers that explicitly create timeless sessions. +type SessionInfo struct { + ID string `json:"id"` + Provider string `json:"provider"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +// SessionPage is a bounded live view, rather than a snapshot across requests. +type SessionPage struct { + Sessions []SessionInfo `json:"sessions"` + HasMore bool `json:"has_more"` +} + +// SessionManager is an optional capability; stateless JWT stores do not support +// it. Subject is the email when present, otherwise UserID. Email-shaped subjects +// are normalized; other UserIDs remain case sensitive. +// RevokeAll invalidates sessions created before its atomic generation change; +// a later login remains possible. It also invalidates legacy, unindexed sessions. +type SessionManager interface { + ListSessions(context.Context, string, int, int) (*SessionPage, error) + RevokeSession(context.Context, string, string) error + RevokeAllSessions(context.Context, string) error +} + +type storedSession struct { + SessionData + SubjectKey string `json:"_subject_key"` + Generation string `json:"_generation,omitempty"` +} + +func digest(value string) string { + h := sha256.Sum256([]byte(value)) + return hex.EncodeToString(h[:]) +} + +func sessionSubject(data *SessionData) string { + if data.Email != "" { + return normalizeSubject(data.Email) + } + return data.UserID +} + +func normalizeSubject(subject string) string { + if at := strings.LastIndex(subject, "@"); at >= 0 { + subject = strings.ToLower(strings.TrimSpace(subject)) + at = strings.LastIndex(subject, "@") + domain, err := idna.Lookup.ToASCII(strings.TrimSuffix(subject[at+1:], ".")) + if err == nil { + subject = subject[:at+1] + domain + } + } + return subject +} + +func (s *RedisOpaqueSessionStore) subjectKey(subject string) string { + return s.prefix + "v2:user:" + digest(normalizeSubject(subject)) +} + +func (s *RedisOpaqueSessionStore) sessionKey(id string) string { + return s.prefix + "v2:session:" + id +} + +// Creation and indexing share a generation with revoke-all atomically. Indexes +// expire with their longest-lived session; old session records retain their TTL +// after bulk revocation. Generation counters intentionally do not expire. +var createSession = redis.NewScript(` +local generation = redis.call('GET', KEYS[2]) or '0' +if ARGV[4] == 'legacy' then + if generation ~= '0' or redis.call('GET', KEYS[3]) ~= ARGV[5] then return 0 end +end +-- Preserve the original JSON: Lua cjson would turn empty claim arrays into +-- objects if it decoded and re-encoded the complete session record. +local record = '{"_generation":' .. cjson.encode(generation) .. ',' .. string.sub(ARGV[1], 2) +local ttl = tonumber(ARGV[2]) +if ttl > 0 then + redis.call('SET', KEYS[1], record, 'PX', ttl) +else + redis.call('SET', KEYS[1], record) +end +local index = ARGV[7] .. ':index:' .. generation +-- Ordinary login traffic must reclaim expired members without ListSessions. +redis.call('ZREMRANGEBYSCORE', index, '-inf', ARGV[8]) +local previous = redis.call('PTTL', index) +redis.call('ZADD', index, ARGV[3], ARGV[6]) +if ttl == 0 or previous == -1 then redis.call('PERSIST', index) +elseif previous < ttl then redis.call('PEXPIRE', index, ttl) end +if ARGV[4] == 'legacy' then redis.call('DEL', KEYS[3]) end +return 1 +`) + +func (s *RedisOpaqueSessionStore) persist(ctx context.Context, id string, data *SessionData, legacy string) (bool, error) { + if data == nil { + return false, errors.New("nil session") + } + now := time.Now() + var ttl int64 + score := "+inf" + if !data.ExpiresAt.IsZero() { + ttl = data.ExpiresAt.Sub(now).Milliseconds() + if ttl <= 0 { + return false, errors.New("session ExpiresAt is in the past") + } + score = strconv.FormatInt(data.ExpiresAt.UnixMilli(), 10) + } + subject := s.subjectKey(sessionSubject(data)) + payload, err := json.Marshal(storedSession{SessionData: *data, SubjectKey: subject}) + if err != nil { + return false, fmt.Errorf("marshal session: %w", err) + } + mode := "new" + if legacy != "" { + mode = "legacy" + } + result, err := createSession.Run(ctx, s.client, []string{s.sessionKey(digest(id)), subject + ":generation", s.prefix + id}, payload, ttl, score, mode, legacy, digest(id), subject, now.UnixMilli()).Int() + return result == 1, err +} + +// New implements SessionStore. The returned 256-bit random value is a bearer +// credential. Redis stores only its digest, which is safe for management APIs. +func (s *RedisOpaqueSessionStore) New(ctx context.Context, data *SessionData) (string, error) { + id, err := newOpaqueID(s.idLen) + if err != nil { + return "", err + } + if _, err = s.persist(ctx, id, data, ""); err != nil { + return "", err + } + return id, nil +} + +func (s *RedisOpaqueSessionStore) managed(ctx context.Context, id string) (*storedSession, error) { + payload, err := s.client.Get(ctx, s.sessionKey(id)).Bytes() + if errors.Is(err, redis.Nil) { + return nil, nil + } + if err != nil { + return nil, err + } + var record storedSession + if err = json.Unmarshal(payload, &record); err != nil { + return nil, err + } + if record.IsExpired() { + return nil, nil + } + generation, err := s.generation(ctx, record.SubjectKey) + if err != nil { + return nil, err + } + if record.Generation != generation { + return nil, nil + } + return &record, nil +} + +func (s *RedisOpaqueSessionStore) generation(ctx context.Context, subjectKey string) (string, error) { + value, err := s.client.Get(ctx, subjectKey+":generation").Result() + if errors.Is(err, redis.Nil) { + return "0", nil + } + return value, err +} + +func validManagementID(id string) bool { + if len(id) != 64 { + return false + } + _, err := hex.DecodeString(id) + return err == nil && id == strings.ToLower(id) +} + +// Get checks the current generation on every request. Pre-management Redis +// sessions are migrated atomically on use, unless revoke-all invalidated them. +// Deploy all replicas together: older Aether versions cannot enforce generations. +func (s *RedisOpaqueSessionStore) Get(ctx context.Context, id string) (*SessionData, error) { + if !validManagementID(id) { + return nil, nil + } + record, err := s.managed(ctx, digest(id)) + if err != nil { + return nil, err + } + if record != nil { + return &record.SessionData, nil + } + payload, err := s.client.Get(ctx, s.prefix+id).Result() + if errors.Is(err, redis.Nil) { + return nil, nil + } + if err != nil { + return nil, err + } + var data SessionData + if err = json.Unmarshal([]byte(payload), &data); err != nil { + return nil, err + } + if data.IsExpired() { + return nil, nil + } + ok, err := s.persist(ctx, id, &data, payload) + if err != nil { + return nil, err + } + if !ok { + // Another request may have completed the same migration. + record, err = s.managed(ctx, digest(id)) + if err != nil || record == nil { + return nil, err + } + return &record.SessionData, nil + } + return &data, nil +} + +// Delete removes the session presented by a bearer cookie, including legacy keys. +func (s *RedisOpaqueSessionStore) Delete(ctx context.Context, id string) error { + if !validManagementID(id) { + return nil + } + return s.remove(ctx, digest(id), s.prefix+id) +} + +var deleteSession = redis.NewScript(` +local raw = redis.call('GET', KEYS[1]) +if raw then + local record = cjson.decode(raw) + local index = record['_subject_key'] .. ':index:' .. record['_generation'] + redis.call('ZREM', index, ARGV[1]) + -- Restore a finite lifetime after the last nonexpiring session is removed. + -- ZREM deletes an empty index; an expired last score also expires the index. + local last = redis.call('ZRANGE', index, -1, -1, 'WITHSCORES') + if #last > 0 and last[2] ~= 'inf' then + redis.call('PEXPIRE', index, tonumber(last[2]) - tonumber(ARGV[2])) + end +end +redis.call('DEL', KEYS[1]) +if #KEYS > 1 then redis.call('DEL', KEYS[2]) end +return 1 +`) + +func (s *RedisOpaqueSessionStore) remove(ctx context.Context, id string, legacyKey string) error { + keys := []string{s.sessionKey(id)} + if legacyKey != "" { + keys = append(keys, legacyKey) + } + return deleteSession.Run(ctx, s.client, keys, id, time.Now().UnixMilli()).Err() +} + +// ListSessions returns unexpired sessions ordered by expiry, then ID. +func (s *RedisOpaqueSessionStore) ListSessions(ctx context.Context, subject string, limit, offset int) (*SessionPage, error) { + if limit < 1 || limit > 200 || offset < 0 || offset > 1000000 { + return nil, errors.New("invalid session pagination") + } + key := s.subjectKey(subject) + generation, err := s.generation(ctx, key) + if err != nil { + return nil, err + } + index := key + ":index:" + generation + if err = s.client.ZRemRangeByScore(ctx, index, "-inf", strconv.FormatInt(time.Now().UnixMilli(), 10)).Err(); err != nil { + return nil, err + } + ids, err := s.client.ZRange(ctx, index, int64(offset), int64(offset+limit)).Result() + if err != nil { + return nil, err + } + page := &SessionPage{Sessions: []SessionInfo{}, HasMore: len(ids) > limit} + if page.HasMore { + ids = ids[:limit] + } + for _, id := range ids { + record, err := s.managed(ctx, id) + if err != nil { + return nil, err + } + if record == nil || record.SubjectKey != key { + continue + } + page.Sessions = append(page.Sessions, SessionInfo{ID: id, Provider: record.Provider, IssuedAt: record.IssuedAt, ExpiresAt: record.ExpiresAt}) + } + return page, nil +} + +// RevokeSession is idempotent. A handle from another subject never revokes that +// subject's session, and management handles cannot be used as bearer cookies. +func (s *RedisOpaqueSessionStore) RevokeSession(ctx context.Context, subject, id string) error { + if !validManagementID(id) { + return errors.New("invalid session management ID") + } + record, err := s.managed(ctx, id) + if err != nil || record == nil { + return err + } + if record.SubjectKey != s.subjectKey(subject) { + return nil + } + return s.remove(ctx, id, "") +} + +var revokeAllSessions = redis.NewScript(` +local generation = redis.call('GET', KEYS[1]) or '0' +redis.call('INCR', KEYS[1]) +redis.call('DEL', ARGV[1] .. ':index:' .. generation) +return 1 +`) + +// RevokeAllSessions invalidates the subject's current generation atomically. +func (s *RedisOpaqueSessionStore) RevokeAllSessions(ctx context.Context, subject string) error { + key := s.subjectKey(subject) + return revokeAllSessions.Run(ctx, s.client, []string{key + ":generation"}, key).Err() +} + +var _ SessionManager = (*RedisOpaqueSessionStore)(nil) diff --git a/server/pkg/authproxy/login/session_management_test.go b/server/pkg/authproxy/login/session_management_test.go new file mode 100644 index 0000000..b34cd21 --- /dev/null +++ b/server/pkg/authproxy/login/session_management_test.go @@ -0,0 +1,304 @@ +package login + +import ( + "context" + "encoding/json" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" +) + +func managementStore(t *testing.T) (*RedisOpaqueSessionStore, *redis.Client) { + t.Helper() + addr := os.Getenv("AUTH_TEST_REDIS_ADDR") + if addr == "" { + addr = miniredis.RunT(t).Addr() + } + client := redis.NewClient(&redis.Options{Addr: addr}) + prefix := "test-session:" + digest(t.Name()+time.Now().String()) + ":" + t.Cleanup(func() { + var cursor uint64 + for { + keys, next, err := client.Scan(context.Background(), cursor, prefix+"*", 100).Result() + if err != nil { + break + } + if len(keys) > 0 { + client.Del(context.Background(), keys...) + } + cursor = next + if cursor == 0 { + break + } + } + client.Close() + }) + return NewRedisOpaqueSessionStore(client, prefix), client +} + +func sessionFixture() *SessionData { + return &SessionData{UserID: "Person@Example.com", Email: " Person@Example.com ", Provider: "google", Claims: map[string]any{"secret": "never-list-claims"}, IssuedAt: time.Now().UTC(), ExpiresAt: time.Now().Add(time.Hour).UTC()} +} + +func TestSessionManagement(t *testing.T) { + ctx := context.Background() + s, client := managementStore(t) + otherReplica := NewRedisOpaqueSessionStore(client, s.prefix) + id, err := s.New(ctx, sessionFixture()) + if err != nil { + t.Fatal(err) + } + page, err := otherReplica.ListSessions(ctx, "person@example.com", 10, 0) + if err != nil || len(page.Sessions) != 1 { + t.Fatalf("list: %+v %v", page, err) + } + handle := page.Sessions[0].ID + encoded, _ := json.Marshal(page) + if strings.Contains(string(encoded), id) || strings.Contains(string(encoded), "never-list-claims") { + t.Fatal("credential/claim leak") + } + if result, err := s.Get(ctx, handle); err != nil || result != nil { + t.Fatal("management ID authenticated") + } + if err := otherReplica.RevokeSession(ctx, "another@example.com", handle); err != nil { + t.Fatal(err) + } + if result, err := s.Get(ctx, id); err != nil || result == nil { + t.Fatal("cross-user revocation") + } + if err := otherReplica.RevokeSession(ctx, "person@example.com", handle); err != nil { + t.Fatal(err) + } + if result, err := s.Get(ctx, id); err != nil || result != nil { + t.Fatal("revoked session authenticated") + } + if err := otherReplica.RevokeSession(ctx, "person@example.com", handle); err != nil { + t.Fatal("not idempotent", err) + } + page, err = s.ListSessions(ctx, "person@example.com", 10, 0) + if err != nil || len(page.Sessions) != 0 { + t.Fatal("revoked session listed") + } +} + +func TestSessionBulkRevocationLegacyAndNewLogin(t *testing.T) { + ctx := context.Background() + s, client := managementStore(t) + legacy, _ := newOpaqueID(32) + unseenLegacy, _ := newOpaqueID(32) + payload, _ := json.Marshal(sessionFixture()) + for _, id := range []string{legacy, unseenLegacy} { + if err := client.Set(ctx, s.prefix+id, payload, time.Hour).Err(); err != nil { + t.Fatal(err) + } + } + if result, err := s.Get(ctx, legacy); err != nil || result == nil { + t.Fatalf("legacy migration: %v", err) + } + page, err := s.ListSessions(ctx, "person@example.com", 10, 0) + if err != nil || len(page.Sessions) != 1 { + t.Fatal("legacy not indexed", err) + } + id, err := s.New(ctx, sessionFixture()) + if err != nil { + t.Fatal(err) + } + if err := s.RevokeAllSessions(ctx, "person@example.com"); err != nil { + t.Fatal(err) + } + for _, token := range []string{id, legacy, unseenLegacy} { + if result, err := s.Get(ctx, token); err != nil || result != nil { + t.Fatalf("revoked token survived: %v", err) + } + } + page, err = s.ListSessions(ctx, "person@example.com", 10, 0) + if err != nil || len(page.Sessions) != 0 { + t.Fatal("bulk revoked sessions listed") + } + id, err = s.New(ctx, sessionFixture()) + if err != nil { + t.Fatal(err) + } + if result, err := s.Get(ctx, id); err != nil || result == nil { + t.Fatalf("subsequent login denied: %v", err) + } + if err := s.Delete(ctx, id); err != nil { + t.Fatal(err) + } + if result, err := s.Get(ctx, id); err != nil || result != nil { + t.Fatal("logout failed") + } +} + +func TestSessionPaginationExpiryAndIndexLifetime(t *testing.T) { + ctx := context.Background() + s, client := managementStore(t) + data := sessionFixture() + for i := range 3 { + data.ExpiresAt = time.Now().Add(time.Duration(i+1) * time.Hour) + if _, err := s.New(ctx, data); err != nil { + t.Fatal(err) + } + } + page, err := s.ListSessions(ctx, "person@example.com", 2, 0) + if err != nil || len(page.Sessions) != 2 || !page.HasMore { + t.Fatal("first page", page, err) + } + page, err = s.ListSessions(ctx, "person@example.com", 2, 2) + if err != nil || len(page.Sessions) != 1 || page.HasMore { + t.Fatal("last page", page, err) + } + index := s.subjectKey("person@example.com") + ":index:0" + if ttl := client.PTTL(ctx, index).Val(); ttl < 2*time.Hour { + t.Fatal("shortened index lifetime", ttl) + } + // Model elapsed time without sleeps: rewrite one record's expiry and score. + key := s.sessionKey(page.Sessions[0].ID) + raw, err := client.Get(ctx, key).Bytes() + if err != nil { + t.Fatal(err) + } + var record storedSession + if err := json.Unmarshal(raw, &record); err != nil { + t.Fatal(err) + } + record.ExpiresAt = time.Now().Add(-time.Hour) + raw, _ = json.Marshal(record) + client.Set(ctx, key, raw, time.Hour) + client.ZAdd(ctx, index, redis.Z{Score: float64(record.ExpiresAt.UnixMilli()), Member: page.Sessions[0].ID}) + page, err = s.ListSessions(ctx, "person@example.com", 10, 0) + if err != nil || len(page.Sessions) != 2 { + t.Fatal("expired session listed", page, err) + } + if _, err := s.New(ctx, &SessionData{ExpiresAt: time.Now().Add(-time.Hour)}); err == nil { + t.Fatal("accepted expired session") + } + if _, err := s.New(ctx, nil); err == nil { + t.Fatal("accepted nil session") + } +} + +func TestSessionConcurrentCreationAndRevocation(t *testing.T) { + ctx := context.Background() + s, _ := managementStore(t) + var wg sync.WaitGroup + ids := make(chan string, 30) + for range 30 { + wg.Add(1) + go func() { + defer wg.Done() + id, err := s.New(ctx, sessionFixture()) + if err != nil { + t.Error(err) + return + } + ids <- id + if err := s.RevokeAllSessions(ctx, "person@example.com"); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + close(ids) + if err := s.RevokeAllSessions(ctx, "person@example.com"); err != nil { + t.Fatal(err) + } + for id := range ids { + if result, err := s.Get(ctx, id); err != nil || result != nil { + t.Fatal("session survived final revocation", err) + } + } +} + +func TestSessionRedisFailureAndPrefixIsolation(t *testing.T) { + ctx := context.Background() + s, client := managementStore(t) + id, err := s.New(ctx, sessionFixture()) + if err != nil { + t.Fatal(err) + } + other := NewRedisOpaqueSessionStore(client, s.prefix+"other:") + if err := other.RevokeAllSessions(ctx, "person@example.com"); err != nil { + t.Fatal(err) + } + if result, err := s.Get(ctx, id); err != nil || result == nil { + t.Fatal("prefix isolation failed") + } + client.Close() + if result, err := s.Get(ctx, id); err == nil || result != nil { + t.Fatal("store failure accepted session") + } + if _, err := s.ListSessions(ctx, "person@example.com", 10, 0); err == nil { + t.Fatal("store failure reported empty inventory") + } + if err := s.RevokeAllSessions(ctx, "person@example.com"); err == nil { + t.Fatal("store failure reported success") + } +} + +func TestSessionSubjectsAndNonexpiringSessions(t *testing.T) { + ctx := context.Background() + s, client := managementStore(t) + data := sessionFixture() + data.Email = " Person@BÜCHER.example. " + data.ExpiresAt = time.Time{} + id, err := s.New(ctx, data) + if err != nil { + t.Fatal(err) + } + page, err := s.ListSessions(ctx, "person@xn--bcher-kva.example", 10, 0) + if err != nil || len(page.Sessions) != 1 { + t.Fatal("IDNA subject mismatch", page, err) + } + // Adding a finite session must not expire an index containing a timeless one. + data.ExpiresAt = time.Now().Add(time.Hour) + if _, err := s.New(ctx, data); err != nil { + t.Fatal(err) + } + if ttl := client.PTTL(ctx, s.subjectKey("person@xn--bcher-kva.example")+":index:0").Val(); ttl != -1 { + t.Fatal("nonexpiring index acquired a TTL", ttl) + } + if err := s.RevokeAllSessions(ctx, "person@xn--bcher-kva.example"); err != nil { + t.Fatal(err) + } + if result, err := s.Get(ctx, id); err != nil || result != nil { + t.Fatal("nonexpiring revocation failed", err) + } + data.Email = "" + data.UserID = "CaseSensitiveSubject" + if _, err := s.New(ctx, data); err != nil { + t.Fatal(err) + } + page, err = s.ListSessions(ctx, "casesensitivesubject", 10, 0) + if err != nil || len(page.Sessions) != 0 { + t.Fatal("folded opaque subject") + } + page, err = s.ListSessions(ctx, "CaseSensitiveSubject", 10, 0) + if err != nil || len(page.Sessions) != 1 { + t.Fatal("missing opaque subject") + } +} + +func TestSessionPreservesClaimJSON(t *testing.T) { + s, _ := managementStore(t) + data := sessionFixture() + data.Claims = map[string]any{"roles": []any{}, "metadata": map[string]any{}, "nested": map[string]any{"groups": []any{}}} + id, err := s.New(context.Background(), data) + if err != nil { + t.Fatal(err) + } + got, err := s.Get(context.Background(), id) + if err != nil || got == nil { + t.Fatal(err) + } + expected, _ := json.Marshal(data.Claims) + actual, _ := json.Marshal(got.Claims) + if string(expected) != string(actual) { + t.Fatalf("changed claim types: want %s got %s", expected, actual) + } +} diff --git a/server/pkg/authproxy/login/session_retention_test.go b/server/pkg/authproxy/login/session_retention_test.go new file mode 100644 index 0000000..b27fcef --- /dev/null +++ b/server/pkg/authproxy/login/session_retention_test.go @@ -0,0 +1,147 @@ +package login + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/redis/go-redis/v9" +) + +func TestSessionCreationPrunesExpiredIndexEntries(t *testing.T) { + for _, legacy := range []bool{false, true} { + name := "new" + if legacy { + name = "legacy" + } + t.Run(name, func(t *testing.T) { + ctx := context.Background() + s, client := managementStore(t) + data := sessionFixture() + oldID, err := s.New(ctx, data) + if err != nil { + t.Fatal(err) + } + data.ExpiresAt = time.Now().Add(3 * time.Hour) + liveID, err := s.New(ctx, data) + if err != nil { + t.Fatal(err) + } + index := s.subjectKey("person@example.com") + ":index:0" + // Model Redis expiry without sleeps: the record disappears while its + // expired member remains in an index kept alive by another session. + if err := client.Del(ctx, s.sessionKey(digest(oldID))).Err(); err != nil { + t.Fatal(err) + } + if err := client.ZAdd(ctx, index, redis.Z{Score: float64(time.Now().Add(-time.Hour).UnixMilli()), Member: digest(oldID)}).Err(); err != nil { + t.Fatal(err) + } + data.ExpiresAt = time.Now().Add(time.Hour) + if legacy { + id, err := newOpaqueID(32) + if err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + if err := client.Set(ctx, s.prefix+id, payload, time.Hour).Err(); err != nil { + t.Fatal(err) + } + if got, err := s.Get(ctx, id); err != nil || got == nil { + t.Fatalf("legacy migration: session=%+v err=%v", got, err) + } + } else if _, err := s.New(ctx, data); err != nil { + t.Fatal(err) + } + // Do not call ListSessions: normal login traffic must reclaim entries + // even when the optional management API is never used. + ids, err := client.ZRange(ctx, index, 0, -1).Result() + if err != nil { + t.Fatal(err) + } + if len(ids) != 2 { + t.Fatalf("index has %d entries for 2 live sessions", len(ids)) + } + for _, id := range ids { + if id == digest(oldID) { + t.Fatal("expired index entry retained") + } + } + if ttl, err := client.PTTL(ctx, index).Result(); err != nil || ttl < 2*time.Hour { + t.Fatalf("longer-lived session lost index lifetime: ttl=%v err=%v", ttl, err) + } + if got, err := s.Get(ctx, liveID); err != nil || got == nil { + t.Fatalf("live session lost: session=%+v err=%v", got, err) + } + }) + } +} + +func TestSessionIndexExpiryAfterNonexpiringRemoval(t *testing.T) { + for _, operation := range []string{"logout", "revoke"} { + for _, remaining := range []string{"none", "finite", "nonexpiring"} { + t.Run(operation+"/"+remaining, func(t *testing.T) { + ctx := context.Background() + s, client := managementStore(t) + data := sessionFixture() + data.ExpiresAt = time.Time{} + id, err := s.New(ctx, data) + if err != nil { + t.Fatal(err) + } + var liveIDs []string + if remaining != "none" { + for _, expiry := range []time.Time{time.Now().Add(time.Hour), time.Now().Add(2 * time.Hour)} { + data.ExpiresAt = expiry + if remaining == "nonexpiring" { + data.ExpiresAt = time.Time{} + } + liveID, err := s.New(ctx, data) + if err != nil { + t.Fatal(err) + } + liveIDs = append(liveIDs, liveID) + } + } + if operation == "logout" { + err = s.Delete(ctx, id) + } else { + err = s.RevokeSession(ctx, "person@example.com", digest(id)) + } + if err != nil { + t.Fatal(err) + } + index := s.subjectKey("person@example.com") + ":index:0" + ttl, err := client.PTTL(ctx, index).Result() + if err != nil { + t.Fatal(err) + } + switch remaining { + case "none": + if ttl != -2 { + t.Fatalf("empty index retained: ttl=%v", ttl) + } + case "finite": + if ttl < time.Hour || ttl > 2*time.Hour { + t.Fatalf("index must expire with its longest-lived remaining session: ttl=%v", ttl) + } + case "nonexpiring": + if ttl != -1 { + t.Fatalf("remaining nonexpiring sessions lost index lifetime: ttl=%v", ttl) + } + } + if got, err := s.Get(ctx, id); err != nil || got != nil { + t.Fatalf("removed session survived: session=%+v err=%v", got, err) + } + for _, liveID := range liveIDs { + if got, err := s.Get(ctx, liveID); err != nil || got == nil { + t.Fatalf("live session lost: session=%+v err=%v", got, err) + } + } + }) + } + } +} diff --git a/server/pkg/authproxy/login/store.go b/server/pkg/authproxy/login/store.go index bfb7dd3..940cec4 100644 --- a/server/pkg/authproxy/login/store.go +++ b/server/pkg/authproxy/login/store.go @@ -13,9 +13,7 @@ import ( "context" "crypto/rand" "encoding/hex" - "encoding/json" "errors" - "fmt" "time" "github.com/redis/go-redis/v9" @@ -64,8 +62,8 @@ type SessionStore interface { var ErrSessionNotFound = errors.New("session not found") // RedisOpaqueSessionStore stores sessions in Redis under -// ":", with the opaque id placed in the cookie. This is -// the production default — server-side revocation is a single Redis DEL. +// a namespaced key derived from the cookie, with per-subject indexes. +// Only the browser receives the bearer credential; management IDs cannot log in. type RedisOpaqueSessionStore struct { client *redis.Client prefix string @@ -85,66 +83,6 @@ func NewRedisOpaqueSessionStore(client *redis.Client, prefix string) *RedisOpaqu // Name implements SessionStore. func (s *RedisOpaqueSessionStore) Name() string { return "redis_opaque" } -// New implements SessionStore. The opaque id is 64 hex chars (32 bytes of -// crypto/rand). TTL is derived from data.ExpiresAt; if unset, the session -// is persisted with no Redis TTL (caller is expected to set a sane default). -func (s *RedisOpaqueSessionStore) New(ctx context.Context, data *SessionData) (string, error) { - id, err := newOpaqueID(s.idLen) - if err != nil { - return "", fmt.Errorf("generate session id: %w", err) - } - payload, err := json.Marshal(data) - if err != nil { - return "", fmt.Errorf("marshal session: %w", err) - } - var ttl time.Duration - if !data.ExpiresAt.IsZero() { - ttl = time.Until(data.ExpiresAt) - if ttl <= 0 { - return "", errors.New("session ExpiresAt is in the past") - } - } - if err := s.client.Set(ctx, s.prefix+id, payload, ttl).Err(); err != nil { - return "", fmt.Errorf("redis set: %w", err) - } - return id, nil -} - -// Get implements SessionStore. -func (s *RedisOpaqueSessionStore) Get(ctx context.Context, id string) (*SessionData, error) { - if id == "" { - return nil, nil - } - payload, err := s.client.Get(ctx, s.prefix+id).Bytes() - if err != nil { - if errors.Is(err, redis.Nil) { - return nil, nil - } - return nil, fmt.Errorf("redis get: %w", err) - } - var data SessionData - if err := json.Unmarshal(payload, &data); err != nil { - return nil, fmt.Errorf("unmarshal session: %w", err) - } - if data.IsExpired() { - // Best-effort cleanup; ignore delete errors. - _ = s.client.Del(ctx, s.prefix+id).Err() - return nil, nil - } - return &data, nil -} - -// Delete implements SessionStore. -func (s *RedisOpaqueSessionStore) Delete(ctx context.Context, id string) error { - if id == "" { - return nil - } - if err := s.client.Del(ctx, s.prefix+id).Err(); err != nil { - return fmt.Errorf("redis del: %w", err) - } - return nil -} - // newOpaqueID returns a hex-encoded random id of n bytes. func newOpaqueID(n int) (string, error) { if n <= 0 {