From 34c461a61c6b8160b35105e079b90c95c7682427 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 6 Nov 2026 12:32:04 +0800 Subject: [PATCH] feat(auth): renew sessions with 30-day idle and 90-day absolute limits --- README.md | 21 +++++ db/004-session-lifetime.sql | 9 ++ deploy/check-schema.sh | 9 +- internal/auth/auth.go | 25 +++--- internal/auth/email.go | 8 +- internal/auth/session.go | 41 +++++++-- internal/auth/session_test.go | 151 ++++++++++++++++++++++++++++++++++ internal/platform/http.go | 8 +- 8 files changed, 245 insertions(+), 27 deletions(-) create mode 100644 db/004-session-lifetime.sql create mode 100644 internal/auth/session_test.go diff --git a/README.md b/README.md index a16a6e8..408519e 100644 --- a/README.md +++ b/README.md @@ -116,3 +116,24 @@ and implements label zoom behavior. Core preserves the supplied membership and label mode without inferring them from positions. Renderer-only `zIndex` is ignored alongside transient state; restore frame layering in the frontend. This extends existing JSONB snapshots and requires no additional migration. + +### Browser session lifetime + +Sessions use random, revocable bearer tokens rather than JWTs. New email and OAuth +logins have a 30-day idle lifetime; authenticated requests atomically renew that +idle deadline, capped at 90 days from issuance. Idle-expired, absolute-expired, +revoked and wrong-project tokens are never revived. Logout removes the session. + +Apply `db/004-session-lifetime.sql` before running this version. Existing sessions +keep their prior idle expiry, so expired logins remain expired. For legacy rows, +the absolute 90-day cap starts at migration time because issuance was not recorded; +an unexpired legacy session gains the new idle lifetime on its next authenticated +request. New rows record their actual creation time and absolute deadline. + +Login and exchange responses return `expires_in: 2592000`. Protected platform +responses and `/auth/me` include `X-Foundry-Session-Expires-In` with the remaining +seconds after renewal, shortened near the absolute deadline. The frontend proxy +must renew its HttpOnly cookie using that value, with a maximum of 30 days. It must +not extend a cookie beyond the backend-provided remaining lifetime, or expose the +session token to client JavaScript. CORS exposes the lifetime header alongside +ETag where applicable. The absolute deadline stays server-authoritative. diff --git a/db/004-session-lifetime.sql b/db/004-session-lifetime.sql new file mode 100644 index 0000000..e6e4a5a --- /dev/null +++ b/db/004-session-lifetime.sql @@ -0,0 +1,9 @@ +BEGIN; + +-- Existing sessions keep their current idle deadline: expired sessions stay expired. +-- Their original issuance time was not recorded, so the new absolute cap starts here. +ALTER TABLE project_sessions + ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(), + ADD COLUMN absolute_expires_at timestamptz NOT NULL DEFAULT (now() + interval '90 days'); + +COMMIT; diff --git a/deploy/check-schema.sh b/deploy/check-schema.sh index 69de8a7..d0b3aef 100644 --- a/deploy/check-schema.sh +++ b/deploy/check-schema.sh @@ -17,7 +17,8 @@ psql <<'SQL' INSERT INTO project_accounts(project,id,google_subject,email) VALUES ('fixture','00000000-0000-0000-0000-000000000001','existing-google','existing@example.invalid'); INSERT INTO project_sessions VALUES -('existing-session','fixture','00000000-0000-0000-0000-000000000001',now()+interval '1 hour'); +('existing-session','fixture','00000000-0000-0000-0000-000000000001',now()+interval '1 hour'), +('expired-session','fixture','00000000-0000-0000-0000-000000000001',now()-interval '1 hour'); SQL psql <<'SQLROLE' CREATE ROLE foundry_fixture LOGIN; @@ -33,6 +34,12 @@ BEGIN JOIN account_identities i ON (i.project,i.account_id)=(a.project,a.id) WHERE s.token_hash='existing-session' AND i.provider='google' AND i.subject='existing-google' ) THEN RAISE EXCEPTION 'Existing Google login was lost'; END IF; + IF EXISTS (SELECT 1 FROM project_sessions WHERE token_hash='expired-session' AND expires_at>now()) THEN + RAISE EXCEPTION 'Migration revived expired session'; + END IF; + IF NOT EXISTS (SELECT 1 FROM project_sessions WHERE token_hash='existing-session' AND absolute_expires_at>now()+interval '89 days') THEN + RAISE EXCEPTION 'Missing absolute session deadline'; + END IF; END $$; BEGIN; INSERT INTO project_accounts(project,id,email) VALUES diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f15c2c6..6149651 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -129,6 +129,7 @@ func (s *service) project(w http.ResponseWriter, r *http.Request) error { return reject(w, 403, "origin_denied") } w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Expose-Headers", SessionExpiryHeader) } switch { case r.Method == http.MethodOptions: @@ -289,8 +290,8 @@ func (s *service) exchange(w http.ResponseWriter, r *http.Request, project strin WHERE token_hash = $1 AND project = $2 AND frontend_challenge = $3 AND expires_at > now() RETURNING account_id ) - INSERT INTO project_sessions - SELECT $4, $2, account_id, now() + interval '8 hours' FROM redeemed_grant + INSERT INTO project_sessions(token_hash,project,account_id,expires_at) + SELECT $4, $2, account_id, now() + interval '30 days' FROM redeemed_grant `, digest(code), project, digest(verifier), digest(session)) if err != nil { return err @@ -302,7 +303,7 @@ func (s *service) exchange(w http.ResponseWriter, r *http.Request, project strin if count == 0 { return reject(w, 401, "invalid_grant") } - reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800}) + reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": SessionIdleSeconds}) return nil } @@ -319,19 +320,21 @@ func (s *service) account(w http.ResponseWriter, r *http.Request, project string reply(w, 204, nil) return nil } - var id string - var email *string - err := s.db.QueryRowContext(r.Context(), ` - SELECT a.id::text, a.email FROM project_sessions s - JOIN project_accounts a ON a.project = s.project AND a.id = s.account_id - WHERE s.token_hash = $1 AND s.project = $2 AND s.expires_at > now() - `, digest(token), project).Scan(&id, &email) - if errors.Is(err, sql.ErrNoRows) { + session, err := AuthenticateSession(r.Context(), s.db, project, r.Header.Get("Authorization")) + if errors.Is(err, ErrInvalidSession) { return reject(w, 401, "invalid_session") } if err != nil { return err } + var email *string + err = s.db.QueryRowContext(r.Context(), "SELECT email FROM project_accounts WHERE project=$1 AND id=$2", project, session.AccountID).Scan(&email) + if err != nil { + return err + } + WriteSessionHeaders(w, session) + id := session.AccountID + reply(w, 200, payload{"project": project, "id": id, "email": email}) return nil } diff --git a/internal/auth/email.go b/internal/auth/email.go index c94749c..4855335 100644 --- a/internal/auth/email.go +++ b/internal/auth/email.go @@ -94,11 +94,11 @@ func (s *service) emailAction(w http.ResponseWriter, r *http.Request, project, a return reject(w, 401, "invalid_credentials") } session := token() - _, err = s.db.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account) + _, err = s.db.ExecContext(r.Context(), "INSERT INTO project_sessions(token_hash,project,account_id,expires_at) VALUES($1,$2,$3,now()+interval '30 days')", digest(session), project, account) if err != nil { return err } - reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800}) + reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": SessionIdleSeconds}) return nil } @@ -205,13 +205,13 @@ SELECT $1,id,$2, return err } session := token() - _, err = tx.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account) + _, err = tx.ExecContext(r.Context(), "INSERT INTO project_sessions(token_hash,project,account_id,expires_at) VALUES($1,$2,$3,now()+interval '30 days')", digest(session), project, account) if err != nil { return err } if err = tx.Commit(); err != nil { return err } - reply(w, 201, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800}) + reply(w, 201, payload{"access_token": session, "token_type": "Bearer", "expires_in": SessionIdleSeconds}) return nil } diff --git a/internal/auth/session.go b/internal/auth/session.go index da62d70..075d8b1 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -4,24 +4,47 @@ import ( "context" "database/sql" "errors" + "net/http" + "strconv" "strings" ) +const SessionIdleSeconds = 30 * 24 * 60 * 60 +const SessionExpiryHeader = "X-Foundry-Session-Expires-In" + var ErrInvalidSession = errors.New("invalid session") -// Authenticate resolves only an unexpired session belonging to this project. -func Authenticate(ctx context.Context, db *sql.DB, project, authorization string) (string, error) { +type Session struct { + AccountID string + ExpiresIn int64 +} + +// AuthenticateSession atomically validates and renews a live session, bounded by +// its absolute deadline. Expired, revoked and wrong-project tokens never renew. +func AuthenticateSession(ctx context.Context, db *sql.DB, project, authorization string) (Session, error) { value, ok := strings.CutPrefix(authorization, "Bearer ") if !ok || !tokenPattern.MatchString(value) { - return "", ErrInvalidSession + return Session{}, ErrInvalidSession } - var id string + var session Session err := db.QueryRowContext(ctx, ` -SELECT account_id::text FROM project_sessions -WHERE project=$1 AND token_hash=$2 AND expires_at>now() -`, project, digest(value)).Scan(&id) +UPDATE project_sessions +SET expires_at=LEAST(now()+interval '30 days',absolute_expires_at) +WHERE project=$1 AND token_hash=$2 AND expires_at>now() AND absolute_expires_at>now() +RETURNING account_id::text,GREATEST(0,FLOOR(EXTRACT(EPOCH FROM (expires_at-now()))))::bigint +`, project, digest(value)).Scan(&session.AccountID, &session.ExpiresIn) if errors.Is(err, sql.ErrNoRows) { - return "", ErrInvalidSession + return Session{}, ErrInvalidSession } - return id, err + return session, err +} + +func WriteSessionHeaders(w http.ResponseWriter, session Session) { + w.Header().Set(SessionExpiryHeader, strconv.FormatInt(session.ExpiresIn, 10)) +} + +// Authenticate preserves the existing API for clients that only need an account ID. +func Authenticate(ctx context.Context, db *sql.DB, project, authorization string) (string, error) { + session, err := AuthenticateSession(ctx, db, project, authorization) + return session.AccountID, err } diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go new file mode 100644 index 0000000..71b36fe --- /dev/null +++ b/internal/auth/session_test.go @@ -0,0 +1,151 @@ +package auth + +import ( + "context" + "database/sql" + "encoding/json" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func sessionFixture(t *testing.T) (*sql.DB, string, string) { + t.Helper() + dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("requires isolated integration database") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + project := "session-" + token()[:12] + var account string + if err = db.QueryRow("INSERT INTO project_accounts(project,id) VALUES($1,gen_random_uuid()) RETURNING id::text", project).Scan(&account); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = db.Exec("DELETE FROM project_accounts WHERE project=$1", project); db.Close() }) + return db, project, account +} + +func TestSessionIdleAndAbsoluteExpiry(t *testing.T) { + db, project, account := sessionFixture(t) + for _, test := range []struct { + name, idle, absolute string + valid bool + }{ + {"active", "1 hour", "90 days", true}, + {"idle-expired", "-1 second", "90 days", false}, + {"absolute-cap", "1 hour", "2 days", true}, + {"absolute-expired", "1 hour", "-1 second", false}, + } { + t.Run(test.name, func(t *testing.T) { + value := token() + _, err := db.Exec("INSERT INTO project_sessions(token_hash,project,account_id,expires_at,absolute_expires_at) VALUES($1,$2,$3,now()+$4::interval,now()+$5::interval)", digest(value), project, account, test.idle, test.absolute) + if err != nil { + t.Fatal(err) + } + var before time.Time + if err := db.QueryRow("SELECT expires_at FROM project_sessions WHERE token_hash=$1", digest(value)).Scan(&before); err != nil { + t.Fatal(err) + } + session, err := AuthenticateSession(context.Background(), db, project, "Bearer "+value) + if !test.valid { + if err != ErrInvalidSession { + t.Fatalf("expired session accepted: %v", err) + } + var after time.Time + if err := db.QueryRow("SELECT expires_at FROM project_sessions WHERE token_hash=$1", digest(value)).Scan(&after); err != nil { + t.Fatal(err) + } + if !after.Equal(before) { + t.Fatal("expired session revived") + } + return + } + if err != nil || session.AccountID != account || session.ExpiresIn <= 0 || session.ExpiresIn > SessionIdleSeconds { + t.Fatalf("invalid renewal result: %+v %v", session, err) + } + var expiry, cap time.Time + if err := db.QueryRow("SELECT expires_at,absolute_expires_at FROM project_sessions WHERE token_hash=$1", digest(value)).Scan(&expiry, &cap); err != nil { + t.Fatal(err) + } + if expiry.After(cap) || (test.name == "absolute-cap" && !expiry.Equal(cap)) { + t.Fatal("absolute cap not enforced") + } + if test.name == "active" && session.ExpiresIn < SessionIdleSeconds-2 { + t.Fatal("active session did not receive 30 idle days") + } + }) + } +} + +func TestSessionIsolationRevocationAndConcurrentRenewal(t *testing.T) { + db, project, account := sessionFixture(t) + value := token() + if _, err := db.Exec("INSERT INTO project_sessions(token_hash,project,account_id,expires_at,absolute_expires_at) VALUES($1,$2,$3,now()+interval '1 hour',now()+interval '2 days')", digest(value), project, account); err != nil { + t.Fatal(err) + } + for _, input := range []struct{ project, authorization string }{{"other", "Bearer " + value}, {project, "Bearer bad-token"}, {project, ""}} { + if _, err := AuthenticateSession(context.Background(), db, input.project, input.authorization); err != ErrInvalidSession { + t.Fatalf("invalid or cross-project token accepted: %v", err) + } + } + var group sync.WaitGroup + for range 4 { + group.Add(1) + go func() { + defer group.Done() + session, err := AuthenticateSession(context.Background(), db, project, "Bearer "+value) + if err != nil || session.ExpiresIn > 2*86400 { + t.Errorf("concurrent renewal escaped cap: %+v %v", session, err) + } + }() + } + group.Wait() + if _, err := db.Exec("DELETE FROM project_sessions WHERE token_hash=$1", digest(value)); err != nil { + t.Fatal(err) + } + if _, err := AuthenticateSession(context.Background(), db, project, "Bearer "+value); err != ErrInvalidSession { + t.Fatal("revoked session renewed") + } +} + +func TestOAuthExchangeIssuesLongLivedSessionAndMeReturnsRemainingLifetime(t *testing.T) { + db, project, account := sessionFixture(t) + code, verifier := token(), token() + _, err := db.Exec("INSERT INTO login_grants(token_hash,project,account_id,frontend_challenge,expires_at) VALUES($1,$2,$3,$4,now()+interval '1 minute')", digest(code), project, account, digest(verifier)) + if err != nil { + t.Fatal(err) + } + s := &service{db: db} + r := httptest.NewRequest("POST", "/exchange", nil) + r.Header.Set("X-Foundry-Code", code) + r.Header.Set("X-Foundry-Verifier", verifier) + out := httptest.NewRecorder() + if err = s.exchange(out, r, project); err != nil || out.Code != 200 { + t.Fatalf("exchange: %d %v", out.Code, err) + } + var result struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if err = json.Unmarshal(out.Body.Bytes(), &result); err != nil || result.ExpiresIn != SessionIdleSeconds { + t.Fatal("incorrect login lifetime") + } + var idle, absolute float64 + if err = db.QueryRow("SELECT EXTRACT(EPOCH FROM(expires_at-created_at)),EXTRACT(EPOCH FROM(absolute_expires_at-created_at)) FROM project_sessions WHERE token_hash=$1", digest(result.AccessToken)).Scan(&idle, &absolute); err != nil || idle < SessionIdleSeconds-2 || absolute != 90*86400 { + t.Fatalf("wrong session deadlines: %f %f %v", idle, absolute, err) + } + r = httptest.NewRequest("GET", "/me", strings.NewReader("")) + r.Header.Set("Authorization", "Bearer "+result.AccessToken) + out = httptest.NewRecorder() + if err = s.account(out, r, project, false); err != nil || out.Header().Get(SessionExpiryHeader) == "" { + t.Fatal("missing renewal header") + } +} diff --git a/internal/platform/http.go b/internal/platform/http.go index 87e9678..6880c5f 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -69,7 +69,7 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H return } w.Header().Set("Access-Control-Allow-Origin", origin) - w.Header().Set("Access-Control-Expose-Headers", "ETag") + w.Header().Set("Access-Control-Expose-Headers", "ETag, "+auth.SessionExpiryHeader) } if r.Method == http.MethodOptions { w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") @@ -77,11 +77,15 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H reply(w, 204, nil) return } - account, err := auth.Authenticate(r.Context(), s.db, project, r.Header.Get("Authorization")) + session, err := auth.AuthenticateSession(r.Context(), s.db, project, r.Header.Get("Authorization")) if errors.Is(err, auth.ErrInvalidSession) { reject(w, 401, "invalid_session") return } + account := session.AccountID + if err == nil { + auth.WriteSessionHeaders(w, session) + } if err == nil && r.PathValue("workspace") != "" { var id string err = s.db.QueryRowContext(r.Context(), "SELECT id::text FROM workspaces WHERE project=$1 AND (id::text=$2 OR slug=$2)", project, r.PathValue("workspace")).Scan(&id) -- 2.52.0