Skip to content

Commit abd41ea

Browse files
feat(auth): absolute-timeout ceiling + slide-on-user-activity (AUTH-1 b+c) (#678)
* feat(auth): absolute-timeout ceiling + slide-on-user-activity (AUTH-1 b+c) Completes AUTH-1 (slice 1 was the client idle timer, #675). (b) Absolute timeout is now a real hard ceiling. The cookie-refresh path used to re-mint a session with a FRESH absolute window on the 7-day refresh token, so the absolute timeout (default 12h) never bit. Carry the original login's absolute deadline through the refresh-token lineage (migration 0047 adds refresh_tokens.absolute_expires_at; stamped at login, copied unchanged on every rotation). ConsumeRefreshToken refuses past the deadline (ErrRefreshSessionExpired -> 401 + clear cookies); a refreshed session inherits the original deadline via IssueSessionWithAbsolute. Legacy tokens (pre-migration) are exempt until they age out within 7 days. (c) The server idle window now tracks real user activity, not HTTP traffic. VerifySession gains WithoutSlide; the binder passes it when a request carries X-Background-Refresh. The SPA API client marks background/poll GETs with that header, gated on the idle-timer activity signal (slice 1's localStorage key). Fail-safe: an unmarked request slides as before, so the change is inert until the activity signal exists -- no premature logout. Spec system-auth-identity v1.4.0: C-28/C-29 + AC-28..AC-31. Backend + frontend tests green; specter 31/31 ACs, 100%. * fix(auth): SSE stream must not slide the idle window; guard zero absolute deadline Security-review follow-ups on AUTH-1 (c): - The /api/v1/events SSE stream authenticates via the binder but EventSource cannot send X-Background-Refresh, and it reconnects through proxy idle timeouts — so each (re)connect slid the idle window, letting an open SPA keep an unattended session alive forever, defeating (c). The binder now passes WithoutSlide for the SSE path. Binder test extended. - IssueSessionWithAbsolute now treats a zero deadline as 'no carried ceiling' and grants a fresh absolute window, instead of capping idle to the zero time (an always-expired session) — defensive against a future caller. Spec system-auth-identity C-29/AC-31 updated.
1 parent 4c3b9e8 commit abd41ea

13 files changed

Lines changed: 506 additions & 34 deletions

File tree

frontend/src/api/client.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,26 @@ const CSRF_HEADER = 'X-CSRF-Token';
2424
const REFRESH_PATH = '/api/v1/auth/refresh-cookie';
2525
const LOGIN_PATH = '/login';
2626

27+
// AUTH-1 (c): the server slides the session idle window only on user-initiated
28+
// requests. We mark background/poll GETs with X-Background-Refresh so they do
29+
// NOT keep an unattended session alive. "User-initiated" is inferred from the
30+
// idle-timer's activity signal (the same localStorage key useIdleLogout writes
31+
// on real pointer/keyboard input). Fail-safe: until that key is written (e.g.
32+
// the idle timer is not present), we never mark, so the server slides exactly
33+
// as before — no premature logout.
34+
const BACKGROUND_HEADER = 'X-Background-Refresh';
35+
const ACTIVITY_KEY = 'ow.session.lastActivity';
36+
let lastReportedActivity = 0;
37+
38+
function lastUserActivity(): number {
39+
try {
40+
const n = Number(localStorage.getItem(ACTIVITY_KEY));
41+
return Number.isFinite(n) ? n : 0;
42+
} catch {
43+
return 0;
44+
}
45+
}
46+
2747
function readCookie(name: string): string | null {
2848
if (typeof document === 'undefined') return null;
2949
const target = name + '=';
@@ -131,8 +151,20 @@ baseClient.use({
131151
onRequest({ request }) {
132152
const method = request.method.toUpperCase();
133153
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
154+
// AUTH-1 (c): a safe read rides genuine user activity (don't mark → the
155+
// server slides) only when there is NEW activity since the last reported
156+
// request; otherwise it is background churn and must not slide the window.
157+
const last = lastUserActivity();
158+
if (last > 0) {
159+
if (last > lastReportedActivity) {
160+
lastReportedActivity = last;
161+
} else {
162+
request.headers.set(BACKGROUND_HEADER, '1');
163+
}
164+
}
134165
return request;
135166
}
167+
// Mutations are always user-initiated → never marked background (they slide).
136168
const token = readCookie(CSRF_COOKIE);
137169
if (token) request.headers.set(CSRF_HEADER, token);
138170
return request;

frontend/tests/api/client-retry.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,3 +207,22 @@ describe('api/client — 401 retry middleware', () => {
207207
expect(navigateSpy).toHaveBeenCalledWith('/login');
208208
});
209209
});
210+
211+
// @ac AC-31
212+
// AC-31 (AUTH-1 c) — the client marks background/poll reads with
213+
// X-Background-Refresh so the server does not slide the idle window for them,
214+
// gated on the idle-timer activity signal (fail-safe: inert until that
215+
// localStorage key is written). Source-inspection of the request middleware.
216+
test('system-auth-identity/AC-31 — marks background reads with X-Background-Refresh, gated on activity', async () => {
217+
const { readFileSync } = await import('node:fs');
218+
const { resolve } = await import('node:path');
219+
const src = readFileSync(resolve(process.cwd(), 'src/api/client.ts'), 'utf8');
220+
// Sets the background marker header...
221+
expect(src).toContain("'X-Background-Refresh'");
222+
// ...gated on the idle-timer activity signal (the fail-safe localStorage key).
223+
expect(src).toContain("'ow.session.lastActivity'");
224+
// ...via the request middleware on safe reads (mutations always slide).
225+
expect(src).toMatch(/BACKGROUND_HEADER/);
226+
// Fail-safe: only marks when an activity timestamp actually exists.
227+
expect(src).toMatch(/last > 0/);
228+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- 0047_refresh_absolute_deadline.sql
2+
--
3+
-- AUTH-1 (b): make the session ABSOLUTE timeout a real ceiling.
4+
--
5+
-- The cookie-refresh path (PostAuthRefreshCookie) re-mints a session on the
6+
-- 7-day refresh token and, before this change, gave the new session a FRESH
7+
-- absolute window — so the absolute timeout (default 12h) never actually bit:
8+
-- a browser could be kept alive for the full 7-day refresh-token life.
9+
--
10+
-- Carry the original login's absolute deadline through the refresh-token
11+
-- lineage: it is stamped at login and copied UNCHANGED on every rotation. Once
12+
-- now > absolute_expires_at, refresh is refused and the chain ends.
13+
--
14+
-- Nullable: refresh tokens minted before this migration (in-flight, up to 7
15+
-- days) carry NULL and are treated as legacy (no absolute ceiling) until they
16+
-- naturally expire — no forced logout on upgrade.
17+
--
18+
-- Spec: system-auth-identity.
19+
20+
-- +goose Up
21+
ALTER TABLE refresh_tokens ADD COLUMN absolute_expires_at TIMESTAMPTZ;
22+
23+
-- +goose Down
24+
ALTER TABLE refresh_tokens DROP COLUMN absolute_expires_at;

internal/identity/binder.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,20 @@ import (
1818
// presentation tokens. Server-set; client reads it back over HTTPS.
1919
const SessionCookieName = "openwatch_session"
2020

21+
// BackgroundRefreshHeader marks a request as NOT user-initiated (a background
22+
// poll, an SSE reconnect) so the binder verifies the session without sliding
23+
// its idle window. The SPA sets it on recurring/background fetches; ordinary
24+
// user-driven navigation and mutations omit it and slide as before. AUTH-1 (c).
25+
const BackgroundRefreshHeader = "X-Background-Refresh"
26+
27+
// sseEventsPath is the live-events SSE stream. EventSource cannot set custom
28+
// headers, so it can never send BackgroundRefreshHeader; yet it is a long-lived
29+
// background subscription that reconnects (often through a proxy idle-timeout).
30+
// Treating it as user activity would let an open SPA keep an unattended session
31+
// alive forever, defeating the idle timeout — so the binder never slides for it.
32+
// AUTH-1 (c).
33+
const sseEventsPath = "/api/v1/events"
34+
2135
// authBypassPaths are credential-lifecycle endpoints where the binder
2236
// MUST NOT 401 on a stale session cookie — they handle their own
2337
// credential semantics. Login does not need any cookie; logout is
@@ -136,7 +150,16 @@ func writeSessionInvalid(w http.ResponseWriter, r *http.Request, reason string)
136150
// presented-but-rejected credentials).
137151
func resolveIdentity(ctx context.Context, pool *pgxpool.Pool, lookups Lookups, cfg binderConfig, r *http.Request) (auth.Identity, string) {
138152
if cookie, err := r.Cookie(SessionCookieName); err == nil && cookie.Value != "" {
139-
sess, err := VerifySession(ctx, pool, cookie.Value)
153+
// AUTH-1 (c): the client marks NON-user-initiated requests (background
154+
// polling, SSE) with X-Background-Refresh so the server does not slide
155+
// the idle window for them — idle then tracks real user activity, not
156+
// HTTP traffic. Fail-safe: an unmarked request slides as before, so a
157+
// client that does not send the header is unaffected.
158+
var vopts []VerifyOption
159+
if r.Header.Get(BackgroundRefreshHeader) == "1" || r.URL.Path == sseEventsPath {
160+
vopts = append(vopts, WithoutSlide())
161+
}
162+
sess, err := VerifySession(ctx, pool, cookie.Value, vopts...)
140163
switch {
141164
case errors.Is(err, ErrSessionNotFound):
142165
return anon(), "invalid_session_token"

internal/identity/binder_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,5 +159,72 @@ func TestBinder_ExpiredSession_Writes401(t *testing.T) {
159159
})
160160
}
161161

162+
// @ac AC-31
163+
// AC-31 (AUTH-1 c): the binder slides the idle window only on user-initiated
164+
// requests. A request carrying X-Background-Refresh validates the session but
165+
// does NOT advance expires_at; an unmarked request does.
166+
func TestBinder_SlideOnlyOnUserActivity(t *testing.T) {
167+
t.Run("system-auth-identity/AC-31", func(t *testing.T) {
168+
pool := freshPool(t)
169+
userID := seedUser(t, pool, "slide-binder")
170+
token, sess, err := IssueSession(context.Background(), pool, userID, "127.0.0.1", "ua")
171+
if err != nil {
172+
t.Fatalf("issue: %v", err)
173+
}
174+
// Backdate expires_at so a slide is observable.
175+
if _, err := pool.Exec(context.Background(),
176+
`UPDATE sessions SET expires_at = expires_at - interval '10 minutes' WHERE id = $1`,
177+
sess.ID); err != nil {
178+
t.Fatalf("backdate: %v", err)
179+
}
180+
readExpiry := func() time.Time {
181+
var e time.Time
182+
if err := pool.QueryRow(context.Background(),
183+
`SELECT expires_at FROM sessions WHERE id = $1`, sess.ID).Scan(&e); err != nil {
184+
t.Fatalf("read expiry: %v", err)
185+
}
186+
return e
187+
}
188+
h := Binder(pool, adminLookups)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
189+
w.WriteHeader(http.StatusOK)
190+
}))
191+
do := func(background bool) {
192+
req := httptest.NewRequest(http.MethodGet, "/api/v1/hosts", nil)
193+
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: token})
194+
if background {
195+
req.Header.Set(BackgroundRefreshHeader, "1")
196+
}
197+
rr := httptest.NewRecorder()
198+
h.ServeHTTP(rr, req)
199+
if rr.Code != http.StatusOK {
200+
t.Fatalf("status: want 200, got %d", rr.Code)
201+
}
202+
}
203+
204+
before := readExpiry()
205+
// Background request must NOT slide.
206+
do(true)
207+
if got := readExpiry(); !got.Equal(before) {
208+
t.Errorf("background request slid the window: before=%v after=%v", before, got)
209+
}
210+
// User-initiated request must slide.
211+
do(false)
212+
if got := readExpiry(); !got.After(before) {
213+
t.Errorf("user request did not slide the window: before=%v after=%v", before, got)
214+
}
215+
216+
// The SSE events stream must NOT slide — it cannot send the header but
217+
// is a long-lived background subscription.
218+
afterUser := readExpiry()
219+
req := httptest.NewRequest(http.MethodGet, sseEventsPath, nil)
220+
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: token})
221+
rr := httptest.NewRecorder()
222+
h.ServeHTTP(rr, req)
223+
if got := readExpiry(); !got.Equal(afterUser) {
224+
t.Errorf("SSE request slid the window: before=%v after=%v", afterUser, got)
225+
}
226+
})
227+
}
228+
162229
// Silence the unused-import warning when the test runs without DB.
163230
var _ = pgxpool.Config{}

internal/identity/jwt_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ func TestRefresh_RotationOnConsume(t *testing.T) {
8484
userID := seedUser(t, pool, "ac12-user")
8585

8686
// Issue an initial refresh token.
87-
refresh1, err := IssueRefreshToken(context.Background(), pool, userID)
87+
refresh1, err := IssueRefreshToken(context.Background(), pool, userID, time.Now().UTC().Add(12*time.Hour))
8888
if err != nil {
8989
t.Fatalf("IssueRefreshToken: %v", err)
9090
}
@@ -135,7 +135,7 @@ func TestRefresh_ReuseDetectionCascadeRevokes(t *testing.T) {
135135
t.Fatalf("IssueSession: %v", err)
136136
}
137137

138-
refresh1, err := IssueRefreshToken(context.Background(), pool, userID)
138+
refresh1, err := IssueRefreshToken(context.Background(), pool, userID, time.Now().UTC().Add(12*time.Hour))
139139
if err != nil {
140140
t.Fatalf("IssueRefreshToken: %v", err)
141141
}

internal/identity/refresh.go

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ var (
2525
ErrRefreshTokenExpired = errors.New("identity: refresh token expired")
2626
ErrRefreshTokenRevoked = errors.New("identity: refresh token revoked")
2727
ErrRefreshTokenReused = errors.New("identity: refresh token reuse detected")
28+
// ErrRefreshSessionExpired — the refresh token is still inside its 7-day
29+
// window, but the session's ABSOLUTE deadline (carried through the lineage
30+
// from login) has passed. Refresh is refused; the user must re-authenticate.
31+
// AUTH-1 (b).
32+
ErrRefreshSessionExpired = errors.New("identity: session absolute timeout reached")
2833
)
2934

3035
// RevokeRefreshToken marks the row identified by presentation-token as
@@ -50,8 +55,14 @@ func RevokeRefreshToken(ctx context.Context, pool *pgxpool.Pool, token string) e
5055
// presentation token. Token is stored as SHA-256 hash; presentation
5156
// form is never in the DB.
5257
//
58+
// absoluteExpiresAt is the session's absolute deadline (login time + the
59+
// configured absolute window). It is carried through every rotation so the
60+
// session cannot be refreshed past it (AUTH-1 b). A zero value stores NULL —
61+
// the legacy "no absolute ceiling" behavior, used only by callers that have no
62+
// session deadline to anchor to.
63+
//
5364
// Spec AC-12.
54-
func IssueRefreshToken(ctx context.Context, pool *pgxpool.Pool, userID uuid.UUID) (token string, err error) {
65+
func IssueRefreshToken(ctx context.Context, pool *pgxpool.Pool, userID uuid.UUID, absoluteExpiresAt time.Time) (token string, err error) {
5566
raw := make([]byte, 32)
5667
if _, err := rand.Read(raw); err != nil {
5768
return "", fmt.Errorf("identity: refresh entropy: %w", err)
@@ -64,21 +75,36 @@ func IssueRefreshToken(ctx context.Context, pool *pgxpool.Pool, userID uuid.UUID
6475
return "", fmt.Errorf("identity: uuid: %w", err)
6576
}
6677
const stmt = `
67-
INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
68-
VALUES ($1, $2, $3, $4)`
69-
if _, err := pool.Exec(ctx, stmt, id, userID, hash[:], time.Now().UTC().Add(RefreshTokenWindow)); err != nil {
78+
INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, absolute_expires_at)
79+
VALUES ($1, $2, $3, $4, $5)`
80+
if _, err := pool.Exec(ctx, stmt, id, userID, hash[:],
81+
time.Now().UTC().Add(RefreshTokenWindow), nullableTime(absoluteExpiresAt)); err != nil {
7082
return "", fmt.Errorf("identity: insert refresh: %w", err)
7183
}
7284
return token, nil
7385
}
7486

87+
// nullableTime returns nil for the zero time (stored as SQL NULL) or the time
88+
// otherwise — so a missing absolute deadline is recorded honestly as "none".
89+
func nullableTime(t time.Time) any {
90+
if t.IsZero() {
91+
return nil
92+
}
93+
return t
94+
}
95+
7596
// TokenPair is the result of a successful ConsumeRefreshToken call —
7697
// a new access JWT plus a new refresh token. The caller delivers both
7798
// to the client; the old refresh token is now revoked.
7899
type TokenPair struct {
79100
AccessToken string
80101
RefreshToken string
81102
Claims Claims
103+
// AbsoluteExpiresAt is the session's carried absolute deadline (AUTH-1 b),
104+
// zero when the consumed token had none (legacy). The cookie-refresh handler
105+
// stamps it onto the re-minted session so the absolute ceiling is preserved
106+
// across refreshes rather than reset.
107+
AbsoluteExpiresAt time.Time
82108
}
83109

84110
// ConsumeRefreshToken atomically:
@@ -106,17 +132,18 @@ func ConsumeRefreshToken(ctx context.Context, pool *pgxpool.Pool, token, role st
106132
defer func() { _ = tx.Rollback(ctx) }()
107133

108134
var (
109-
rowID uuid.UUID
110-
userID uuid.UUID
111-
expiresAt time.Time
112-
rotatedTo *uuid.UUID
113-
revokedAt *time.Time
135+
rowID uuid.UUID
136+
userID uuid.UUID
137+
expiresAt time.Time
138+
absoluteExp *time.Time
139+
rotatedTo *uuid.UUID
140+
revokedAt *time.Time
114141
)
115142
err = tx.QueryRow(ctx, `
116-
SELECT id, user_id, expires_at, rotated_to_id, revoked_at
143+
SELECT id, user_id, expires_at, absolute_expires_at, rotated_to_id, revoked_at
117144
FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE`,
118145
hash[:],
119-
).Scan(&rowID, &userID, &expiresAt, &rotatedTo, &revokedAt)
146+
).Scan(&rowID, &userID, &expiresAt, &absoluteExp, &rotatedTo, &revokedAt)
120147
if err != nil {
121148
if errors.Is(err, pgx.ErrNoRows) {
122149
return nil, ErrRefreshTokenNotFound
@@ -130,6 +157,13 @@ func ConsumeRefreshToken(ctx context.Context, pool *pgxpool.Pool, token, role st
130157
if time.Now().UTC().After(expiresAt) {
131158
return nil, ErrRefreshTokenExpired
132159
}
160+
// AUTH-1 (b): the session's absolute deadline is a hard ceiling. Once it
161+
// passes, the chain ends even though the 7-day refresh window is still
162+
// open. Legacy tokens (absolute_expires_at NULL) are exempt until they age
163+
// out. Checked before reuse so an expired-session token simply fails closed.
164+
if absoluteExp != nil && time.Now().UTC().After(*absoluteExp) {
165+
return nil, ErrRefreshSessionExpired
166+
}
133167
if rotatedTo != nil {
134168
// Reuse! This row was already consumed. An attacker has the old
135169
// presentation token. Revoke everything for this user.
@@ -167,10 +201,12 @@ func ConsumeRefreshToken(ctx context.Context, pool *pgxpool.Pool, token, role st
167201
if err != nil {
168202
return nil, fmt.Errorf("identity: uuid: %w", err)
169203
}
204+
// Carry the original absolute deadline UNCHANGED onto the rotated row, so
205+
// the absolute ceiling cannot be reset by refreshing (AUTH-1 b).
170206
if _, err := tx.Exec(ctx, `
171-
INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
172-
VALUES ($1, $2, $3, $4)`,
173-
newID, userID, newHash[:], time.Now().UTC().Add(RefreshTokenWindow),
207+
INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, absolute_expires_at)
208+
VALUES ($1, $2, $3, $4, $5)`,
209+
newID, userID, newHash[:], time.Now().UTC().Add(RefreshTokenWindow), absoluteExp,
174210
); err != nil {
175211
return nil, fmt.Errorf("identity: insert rotated refresh: %w", err)
176212
}
@@ -188,9 +224,13 @@ func ConsumeRefreshToken(ctx context.Context, pool *pgxpool.Pool, token, role st
188224
if err := tx.Commit(ctx); err != nil {
189225
return nil, fmt.Errorf("identity: refresh commit (happy): %w", err)
190226
}
191-
return &TokenPair{
227+
pair := &TokenPair{
192228
AccessToken: access,
193229
RefreshToken: newPres,
194230
Claims: claims,
195-
}, nil
231+
}
232+
if absoluteExp != nil {
233+
pair.AbsoluteExpiresAt = *absoluteExp
234+
}
235+
return pair, nil
196236
}

0 commit comments

Comments
 (0)