Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7314895ec4 | ||
|
|
36dd182fb5 |
@@ -1128,6 +1128,40 @@ Spec: `docs/specs/onboarding-wow-burst.md`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-029 — Stateless session cookie (survives restarts, browser-close, idle)
|
||||||
|
|
||||||
|
**Status:** Accepted (2026-06-11). Triggered by pilot feedback: "lots of clicking to log in again
|
||||||
|
on iPhone." Supersedes the in-memory session store in the ADR-011 login.
|
||||||
|
|
||||||
|
**Context.** Three compounding causes made users re-login constantly:
|
||||||
|
1. **In-memory session store** (`sessionStore` map) — wiped on every pod restart, so each deploy
|
||||||
|
logged everyone out. During the active build period that was ~15 logouts.
|
||||||
|
2. **1-hour session TTL** — for a "check back tomorrow" reader, idle > 1h forced a re-login on
|
||||||
|
nearly every visit.
|
||||||
|
3. **No cookie Max-Age** — a session cookie (deleted on browser/app close); iPhone Safari closing
|
||||||
|
the tab dropped it.
|
||||||
|
Each re-login is the full Dex/Authentik redirect dance — many taps on mobile.
|
||||||
|
|
||||||
|
**Decision.** Make the session **stateless**: the identity (subject + email) and an absolute
|
||||||
|
expiry live INSIDE the existing HMAC-signed (HS256) cookie — no server-side table. Plus:
|
||||||
|
- **30-day sliding TTL** (was 1h), re-signed on each request so an active user never lapses.
|
||||||
|
- **Persistent cookie** (`Max-Age` set) so it survives browser/app close.
|
||||||
|
The cookie is HttpOnly + Secure + SameSite=Lax; the HMAC (keyed by the stable ESO
|
||||||
|
`tapir-session-secret`, which does NOT rotate per deploy) makes it tamper-proof. The payload is
|
||||||
|
identity, not secrets — the OIDC access/ID tokens are still discarded after callback.
|
||||||
|
|
||||||
|
**Consequences.** A deploy/restart no longer logs anyone out (proven by a test: a cookie issued by
|
||||||
|
one instance is accepted by a fresh instance with the same secret); works across replicas for
|
||||||
|
free. **Trade:** no server-side revocation — `logout` clears the cookie client-side, but a copied
|
||||||
|
cookie stays valid until expiry. Accepted for the Stage-0 reader pilot; revisit (server-side
|
||||||
|
revocation list, or shorter TTL + refresh) if it ever holds sensitive actions. Rotating
|
||||||
|
`tapir-session-secret` invalidates all sessions — the global logout lever.
|
||||||
|
|
||||||
|
**Not addressed here:** the tap-count of the IdP login page itself is Authentik's UX; with
|
||||||
|
re-login now rare (30-day idle or explicit logout), it matters far less.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Rejected alternatives
|
## Rejected alternatives
|
||||||
|
|
||||||
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
|
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
|
||||||
|
|||||||
+33
-3
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
@@ -14,6 +15,27 @@ import (
|
|||||||
// weeks. The gate passes when any user reaches it.
|
// weeks. The gate passes when any user reaches it.
|
||||||
const gateThreshold = 2
|
const gateThreshold = 2
|
||||||
|
|
||||||
|
// defaultGateStart is the date Stage-0 return-usage tracking begins: the morning
|
||||||
|
// the pilot was actually unblocked and summaries started flowing (2026-06-11).
|
||||||
|
// Activity before this — testing, the period the pilot was stuck on zero — is
|
||||||
|
// noise and must not count toward the gate. Override with TAPIR_USAGE_GATE_START
|
||||||
|
// (YYYY-MM-DD). The gate measures whether users RETURN once it genuinely works.
|
||||||
|
const defaultGateStart = "2026-06-11"
|
||||||
|
|
||||||
|
// gateStart resolves the baseline date from TAPIR_USAGE_GATE_START or the default,
|
||||||
|
// parsed as a UTC calendar day.
|
||||||
|
func gateStart() (time.Time, error) {
|
||||||
|
v := os.Getenv("TAPIR_USAGE_GATE_START")
|
||||||
|
if v == "" {
|
||||||
|
v = defaultGateStart
|
||||||
|
}
|
||||||
|
t, err := time.Parse("2006-01-02", v)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("TAPIR_USAGE_GATE_START=%q: want YYYY-MM-DD: %w", v, err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
// runReport prints the Stage-0 usage gate: per-user distinct active weeks (reads
|
// runReport prints the Stage-0 usage gate: per-user distinct active weeks (reads
|
||||||
// UNION acts) and the pass/fail verdict. Read-only, cross-user — needs only
|
// UNION acts) and the pass/fail verdict. Read-only, cross-user — needs only
|
||||||
// TAPIR_DB_DSN (not TAPIR_USER_ID; the report enumerates all users itself).
|
// TAPIR_DB_DSN (not TAPIR_USER_ID; the report enumerates all users itself).
|
||||||
@@ -28,16 +50,24 @@ func runReport(ctx context.Context, _ []string) error {
|
|||||||
}
|
}
|
||||||
defer s.Close()
|
defer s.Close()
|
||||||
|
|
||||||
rows, err := s.ActiveWeeks(ctx)
|
since, err := gateStart()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return formatReport(os.Stdout, rows)
|
|
||||||
|
rows, err := s.ActiveWeeks(ctx, since)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return formatReport(os.Stdout, rows, since)
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatReport renders the per-user week counts and the gate verdict. Pure: no DB,
|
// formatReport renders the per-user week counts and the gate verdict. Pure: no DB,
|
||||||
// no env — so the layout and verdict logic are unit-testable without Postgres.
|
// no env — so the layout and verdict logic are unit-testable without Postgres.
|
||||||
func formatReport(w io.Writer, rows []store.UserActiveWeeks) error {
|
func formatReport(w io.Writer, rows []store.UserActiveWeeks, since time.Time) error {
|
||||||
|
if _, err := fmt.Fprintf(w, "Counting usage since %s (Stage-0 gate baseline)\n\n", since.Format("2006-01-02")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
_, err := fmt.Fprintln(w, "no users yet")
|
_, err := fmt.Fprintln(w, "no users yet")
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ package main
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var testSince = time.Date(2026, 6, 11, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
func TestFormatReportColumnsAndGatePass(t *testing.T) {
|
func TestFormatReportColumnsAndGatePass(t *testing.T) {
|
||||||
rows := []store.UserActiveWeeks{
|
rows := []store.UserActiveWeeks{
|
||||||
{UserID: "user-a", DisplayName: "Ada", ActiveWeeks: 3},
|
{UserID: "user-a", DisplayName: "Ada", ActiveWeeks: 3},
|
||||||
@@ -16,9 +19,10 @@ func TestFormatReportColumnsAndGatePass(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
require.NoError(t, formatReport(&b, rows))
|
require.NoError(t, formatReport(&b, rows, testSince))
|
||||||
out := b.String()
|
out := b.String()
|
||||||
|
|
||||||
|
require.Contains(t, out, "since 2026-06-11", "report states the gate baseline date")
|
||||||
require.Contains(t, out, "USER")
|
require.Contains(t, out, "USER")
|
||||||
require.Contains(t, out, "ACTIVE_WEEKS")
|
require.Contains(t, out, "ACTIVE_WEEKS")
|
||||||
require.Contains(t, out, "Ada")
|
require.Contains(t, out, "Ada")
|
||||||
@@ -34,12 +38,12 @@ func TestFormatReportGateNotMet(t *testing.T) {
|
|||||||
rows := []store.UserActiveWeeks{{UserID: "user-a", ActiveWeeks: 1}}
|
rows := []store.UserActiveWeeks{{UserID: "user-a", ActiveWeeks: 1}}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
require.NoError(t, formatReport(&b, rows))
|
require.NoError(t, formatReport(&b, rows, testSince))
|
||||||
require.Contains(t, b.String(), "NOT YET MET", "no user at >= 2 weeks fails the gate")
|
require.Contains(t, b.String(), "NOT YET MET", "no user at >= 2 weeks fails the gate")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormatReportEmpty(t *testing.T) {
|
func TestFormatReportEmpty(t *testing.T) {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
require.NoError(t, formatReport(&b, nil))
|
require.NoError(t, formatReport(&b, nil, testSince))
|
||||||
require.Contains(t, b.String(), "no users yet")
|
require.Contains(t, b.String(), "no users yet")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,6 +224,9 @@ knobs plus one load-bearing deployment constraint:
|
|||||||
- `TAPIR_DISCOVERY_INTERVAL` — Go duration, e.g. `2h`. The cadence the serve process runs a
|
- `TAPIR_DISCOVERY_INTERVAL` — Go duration, e.g. `2h`. The cadence the serve process runs a
|
||||||
discovery pass for every registered user (run-once-on-startup, then every interval).
|
discovery pass for every registered user (run-once-on-startup, then every interval).
|
||||||
**Unset or `0` = disabled** (dev/tests never auto-fetch).
|
**Unset or `0` = disabled** (dev/tests never auto-fetch).
|
||||||
|
- `TAPIR_USAGE_GATE_START` — `YYYY-MM-DD`, default **`2026-06-11`** (the morning the pilot was
|
||||||
|
unblocked and summaries started flowing). `tapir report` counts return-usage (distinct active
|
||||||
|
weeks, ADR-016) only from this date, so pre-launch testing and the blocked period are excluded.
|
||||||
- `TAPIR_FETCH_RATE` — Go duration, default `2s`. The **process-wide per-egress-IP caption-fetch
|
- `TAPIR_FETCH_RATE` — Go duration, default `2s`. The **process-wide per-egress-IP caption-fetch
|
||||||
rate gate** (ADR-014 item 2). Every caption fetch — scheduler runners *and* the web "Summarize"
|
rate gate** (ADR-014 item 2). Every caption fetch — scheduler runners *and* the web "Summarize"
|
||||||
click-path — serialises through this one limiter so the pod cannot collectively trip 429s. `0`
|
click-path — serialises through this one limiter so the pod cannot collectively trip 429s. `0`
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
@@ -32,7 +33,12 @@ type UserActiveWeeks struct {
|
|||||||
// Scope note: the enumeration covers users with a Dex identity (the web users the
|
// Scope note: the enumeration covers users with a Dex identity (the web users the
|
||||||
// gate is about). A CLI-only user created by the store sink without an identity
|
// gate is about). A CLI-only user created by the store sink without an identity
|
||||||
// row would not appear — out of scope for this gate.
|
// row would not appear — out of scope for this gate.
|
||||||
func (s *Store) ActiveWeeks(ctx context.Context) ([]UserActiveWeeks, error) {
|
// ActiveWeeks counts each user's distinct active weeks from `since` onward. A zero
|
||||||
|
// `since` means no lower bound (count all history). The Stage-0 gate baseline is
|
||||||
|
// set by the caller (the report command) to the date real usage tracking began,
|
||||||
|
// so pre-launch noise — testing, the period the pilot was blocked — does not count
|
||||||
|
// toward the return-usage signal (ADR-016).
|
||||||
|
func (s *Store) ActiveWeeks(ctx context.Context, since time.Time) ([]UserActiveWeeks, error) {
|
||||||
userIDs, err := s.identityUserIDs(ctx)
|
userIDs, err := s.identityUserIDs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -40,7 +46,7 @@ func (s *Store) ActiveWeeks(ctx context.Context) ([]UserActiveWeeks, error) {
|
|||||||
|
|
||||||
out := make([]UserActiveWeeks, 0, len(userIDs))
|
out := make([]UserActiveWeeks, 0, len(userIDs))
|
||||||
for _, uid := range userIDs {
|
for _, uid := range userIDs {
|
||||||
row, err := s.activeWeeksFor(ctx, uid)
|
row, err := s.activeWeeksFor(ctx, uid, since)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -85,18 +91,18 @@ func (s *Store) identityUserIDs(ctx context.Context) ([]string, error) {
|
|||||||
// activeWeeksFor counts one user's distinct active weeks (reads UNION acts) and
|
// activeWeeksFor counts one user's distinct active weeks (reads UNION acts) and
|
||||||
// reads their display name, RLS-scoped via withUser. The UNION dedups a week that
|
// reads their display name, RLS-scoped via withUser. The UNION dedups a week that
|
||||||
// has both a login and an action so it counts once.
|
// has both a login and an action so it counts once.
|
||||||
func (s *Store) activeWeeksFor(ctx context.Context, userID string) (UserActiveWeeks, error) {
|
func (s *Store) activeWeeksFor(ctx context.Context, userID string, since time.Time) (UserActiveWeeks, error) {
|
||||||
res := UserActiveWeeks{UserID: userID}
|
res := UserActiveWeeks{UserID: userID}
|
||||||
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
if err := tx.QueryRow(ctx,
|
if err := tx.QueryRow(ctx,
|
||||||
`WITH weeks AS (
|
`WITH weeks AS (
|
||||||
SELECT date_trunc('week', seen_at) AS wk
|
SELECT date_trunc('week', seen_at) AS wk
|
||||||
FROM login_events WHERE user_id = $1
|
FROM login_events WHERE user_id = $1 AND seen_at >= $2
|
||||||
UNION
|
UNION
|
||||||
SELECT date_trunc('week', acted_at)
|
SELECT date_trunc('week', acted_at)
|
||||||
FROM summary_actions WHERE user_id = $1
|
FROM summary_actions WHERE user_id = $1 AND acted_at >= $2
|
||||||
)
|
)
|
||||||
SELECT count(DISTINCT wk) FROM weeks`, userID).Scan(&res.ActiveWeeks); err != nil {
|
SELECT count(DISTINCT wk) FROM weeks`, userID, since).Scan(&res.ActiveWeeks); err != nil {
|
||||||
return fmt.Errorf("store: count active weeks: %w", err)
|
return fmt.Errorf("store: count active weeks: %w", err)
|
||||||
}
|
}
|
||||||
if err := tx.QueryRow(ctx,
|
if err := tx.QueryRow(ctx,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package store_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -54,7 +55,7 @@ func TestActiveWeeksCountsDistinctWeeksAcrossReadsAndActs(t *testing.T) {
|
|||||||
($1, 'vid-2', 'saved', '2026-01-19T18:00:00Z')`, userA)
|
($1, 'vid-2', 'saved', '2026-01-19T18:00:00Z')`, userA)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
got, err := s.ActiveWeeks(ctx)
|
got, err := s.ActiveWeeks(ctx, time.Time{}) // zero since = no lower bound
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, got, 2, "both identity users must appear")
|
require.Len(t, got, 2, "both identity users must appear")
|
||||||
|
|
||||||
@@ -72,7 +73,33 @@ func TestActiveWeeksEmptyWhenNoUsers(t *testing.T) {
|
|||||||
s := newStore(t)
|
s := newStore(t)
|
||||||
resetDB(t, rawPool(t))
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
got, err := s.ActiveWeeks(ctx)
|
got, err := s.ActiveWeeks(ctx, time.Time{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Empty(t, got)
|
require.Empty(t, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestActiveWeeksExcludesBeforeGateStart proves the baseline cutoff: activity
|
||||||
|
// before `since` does not count, so pre-launch noise (testing, the pilot's blocked
|
||||||
|
// period) is excluded from the Stage-0 return-usage gate (ADR-016).
|
||||||
|
func TestActiveWeeksExcludesBeforeGateStart(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
|
||||||
|
seedReportUser(t, p, userA, "subject-a", "Ada")
|
||||||
|
|
||||||
|
// One read well before the baseline, two reads in distinct weeks after it.
|
||||||
|
_, err := p.Exec(ctx,
|
||||||
|
`INSERT INTO login_events (user_id, seen_at) VALUES
|
||||||
|
($1, '2026-05-01T09:00:00Z'),
|
||||||
|
($1, '2026-06-12T09:00:00Z'),
|
||||||
|
($1, '2026-06-19T09:00:00Z')`, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
since := time.Date(2026, 6, 11, 0, 0, 0, 0, time.UTC)
|
||||||
|
got, err := s.ActiveWeeks(ctx, since)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
require.Equal(t, 2, got[0].ActiveWeeks, "only the two post-baseline weeks count; the May read is excluded")
|
||||||
|
}
|
||||||
|
|||||||
+36
-44
@@ -7,10 +7,11 @@
|
|||||||
// Authentication is real (Dex OIDC) and is the only gate: any Dex-authenticated
|
// Authentication is real (Dex OIDC) and is the only gate: any Dex-authenticated
|
||||||
// subject may sign in (ADR-012 dropped ADR-011's single-subject allowlist).
|
// subject may sign in (ADR-012 dropped ADR-011's single-subject allowlist).
|
||||||
// Authorization/registration is layered on top in internal/web (an authenticated
|
// Authorization/registration is layered on top in internal/web (an authenticated
|
||||||
// subject with no tapir user is routed to registration). Sessions are server-side
|
// subject with no tapir user is routed to registration). Sessions are STATELESS
|
||||||
// (in-memory, fine for the single Stage-1 replica) addressed by an HMAC-signed
|
// (ADR-029): the identity + expiry live inside an HMAC-signed (HS256) HttpOnly
|
||||||
// (HS256) HttpOnly Secure SameSite=Lax cookie with a short TTL and sliding
|
// Secure SameSite=Lax persistent cookie with a long sliding TTL — no server-side
|
||||||
// refresh. Tokens are never logged.
|
// table, so a deploy/restart never logs anyone out and the cookie also survives
|
||||||
|
// browser-close. Tokens are never logged; logout clears the cookie client-side.
|
||||||
//
|
//
|
||||||
// This is mcp-chassis's cousin but NOT the same code: mcp-chassis validates
|
// This is mcp-chassis's cousin but NOT the same code: mcp-chassis validates
|
||||||
// inbound Bearer JWTs for MCP APIs; this is a browser session login.
|
// inbound Bearer JWTs for MCP APIs; this is a browser session login.
|
||||||
@@ -47,7 +48,11 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultSessionTTL = time.Hour
|
// defaultSessionTTL is generous and sliding: Tapir is a "check back tomorrow"
|
||||||
|
// reader, so a short TTL meant a re-login (full IdP redirect dance) on almost
|
||||||
|
// every visit. 30 days, slid forward on each request, keeps a regular user
|
||||||
|
// logged in indefinitely while an abandoned session still lapses.
|
||||||
|
defaultSessionTTL = 30 * 24 * time.Hour
|
||||||
pendingTTL = 10 * time.Minute
|
pendingTTL = 10 * time.Minute
|
||||||
sessionCookie = "tapir_session"
|
sessionCookie = "tapir_session"
|
||||||
loginPath = "/auth/login"
|
loginPath = "/auth/login"
|
||||||
@@ -59,7 +64,6 @@ type DexAuth struct {
|
|||||||
oauth *oauth2.Config
|
oauth *oauth2.Config
|
||||||
verifier *oidc.IDTokenVerifier
|
verifier *oidc.IDTokenVerifier
|
||||||
|
|
||||||
sessions *sessionStore
|
|
||||||
pending *pendingStore
|
pending *pendingStore
|
||||||
secret []byte
|
secret []byte
|
||||||
sessionTTL time.Duration
|
sessionTTL time.Duration
|
||||||
@@ -127,7 +131,6 @@ func New(ctx context.Context, cfg Config, opts ...Option) (*DexAuth, error) {
|
|||||||
RedirectURL: cfg.RedirectURL,
|
RedirectURL: cfg.RedirectURL,
|
||||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||||
},
|
},
|
||||||
sessions: newSessionStore(),
|
|
||||||
pending: newPendingStore(),
|
pending: newPendingStore(),
|
||||||
secret: []byte(cfg.SessionSecret),
|
secret: []byte(cfg.SessionSecret),
|
||||||
sessionTTL: defaultSessionTTL,
|
sessionTTL: defaultSessionTTL,
|
||||||
@@ -159,31 +162,31 @@ func (d *DexAuth) Middleware(h http.Handler) http.Handler {
|
|||||||
h.ServeHTTP(w, r)
|
h.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sid, ok := d.sessionID(r)
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
d.redirectUnauthenticated(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, _, ok := d.decodeSession(c.Value, d.now())
|
||||||
if !ok {
|
if !ok {
|
||||||
d.redirectUnauthenticated(w, r)
|
d.redirectUnauthenticated(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := d.sessions.get(sid, d.now()); !ok {
|
// Sliding refresh: re-issue the cookie with a fresh expiry so an active
|
||||||
d.redirectUnauthenticated(w, r)
|
// user never lapses (the expiry lives in the cookie, so sliding = re-sign).
|
||||||
return
|
d.setSessionCookie(w, d.encodeSession(user, d.now().Add(d.sessionTTL)))
|
||||||
}
|
|
||||||
d.sessions.refresh(sid, d.now().Add(d.sessionTTL)) // sliding refresh
|
|
||||||
h.ServeHTTP(w, r)
|
h.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CurrentUser resolves the authenticated principal from the session cookie.
|
// CurrentUser resolves the authenticated principal from the stateless cookie.
|
||||||
func (d *DexAuth) CurrentUser(r *http.Request) (web.User, bool) {
|
func (d *DexAuth) CurrentUser(r *http.Request) (web.User, bool) {
|
||||||
sid, ok := d.sessionID(r)
|
c, err := r.Cookie(sessionCookie)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return web.User{}, false
|
return web.User{}, false
|
||||||
}
|
}
|
||||||
data, ok := d.sessions.get(sid, d.now())
|
user, _, ok := d.decodeSession(c.Value, d.now())
|
||||||
if !ok {
|
return user, ok
|
||||||
return web.User{}, false
|
|
||||||
}
|
|
||||||
return data.user, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DexAuth) handleLogin(w http.ResponseWriter, r *http.Request) {
|
func (d *DexAuth) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -248,23 +251,15 @@ func (d *DexAuth) handleCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
_ = idToken.Claims(&claims) // email is best-effort; subject is the identity
|
_ = idToken.Claims(&claims) // email is best-effort; subject is the identity
|
||||||
|
|
||||||
sid, err := randToken()
|
user := web.User{Subject: idToken.Subject, Email: claims.Email}
|
||||||
if err != nil {
|
d.setSessionCookie(w, d.encodeSession(user, d.now().Add(d.sessionTTL)))
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d.sessions.put(sid, sessionData{
|
|
||||||
user: web.User{Subject: idToken.Subject, Email: claims.Email},
|
|
||||||
expiry: d.now().Add(d.sessionTTL),
|
|
||||||
})
|
|
||||||
d.setSessionCookie(w, sid)
|
|
||||||
http.Redirect(w, r, "/", http.StatusFound)
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DexAuth) handleLogout(w http.ResponseWriter, r *http.Request) {
|
func (d *DexAuth) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
if sid, ok := d.sessionID(r); ok {
|
// Stateless sessions: clearing the cookie logs the browser out. There is no
|
||||||
d.sessions.delete(sid)
|
// server-side record to delete (ADR-029); a copy of the cookie stays valid
|
||||||
}
|
// until its expiry — an accepted trade for the Stage-0 reader app.
|
||||||
d.clearSessionCookie(w)
|
d.clearSessionCookie(w)
|
||||||
// Land on the public landing page, not the login endpoint: a just-logged-out
|
// Land on the public landing page, not the login endpoint: a just-logged-out
|
||||||
// visitor should see /welcome, not be bounced straight back into a Dex login.
|
// visitor should see /welcome, not be bounced straight back into a Dex login.
|
||||||
@@ -287,22 +282,19 @@ func (d *DexAuth) redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, loginPath, http.StatusFound)
|
http.Redirect(w, r, loginPath, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DexAuth) sessionID(r *http.Request) (string, bool) {
|
// setSessionCookie writes the signed session value as a PERSISTENT cookie
|
||||||
c, err := r.Cookie(sessionCookie)
|
// (Max-Age set), so it survives the browser/app being closed — a session cookie
|
||||||
if err != nil {
|
// (no Max-Age) was dropped on iPhone Safari close, forcing re-login. value is the
|
||||||
return "", false
|
// already-signed payload from encodeSession.
|
||||||
}
|
func (d *DexAuth) setSessionCookie(w http.ResponseWriter, value string) {
|
||||||
return d.unsign(c.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DexAuth) setSessionCookie(w http.ResponseWriter, sid string) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: sessionCookie,
|
Name: sessionCookie,
|
||||||
Value: d.sign(sid),
|
Value: value,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: !d.insecure,
|
Secure: !d.insecure,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: int(d.sessionTTL.Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -306,13 +306,42 @@ func TestLogoutClearsSession(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusFound, rec.Code)
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
require.Equal(t, "/welcome", rec.Header().Get("Location"), "logout lands on the public page")
|
require.Equal(t, "/welcome", rec.Header().Get("Location"), "logout lands on the public page")
|
||||||
cleared := sessionCookie(t, rec.Result())
|
cleared := sessionCookie(t, rec.Result())
|
||||||
require.Less(t, cleared.MaxAge, 0, "logout expires the cookie")
|
require.Less(t, cleared.MaxAge, 0, "logout expires the cookie so the browser drops it")
|
||||||
|
require.Empty(t, cleared.Value, "logout blanks the cookie value")
|
||||||
|
|
||||||
// The server-side session is gone: the original cookie no longer resolves.
|
// Sessions are stateless (ADR-029): logout clears the cookie client-side, so a
|
||||||
|
// request carrying the cleared (empty) cookie is unauthenticated. The original
|
||||||
|
// signed cookie remains technically valid until its expiry — the accepted
|
||||||
|
// trade for no server-side store; the browser no longer holds it.
|
||||||
check := httptest.NewRequest(http.MethodGet, "/", nil)
|
check := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
check.AddCookie(cookie)
|
check.AddCookie(cleared)
|
||||||
_, ok := auth.CurrentUser(check)
|
_, ok := auth.CurrentUser(check)
|
||||||
require.False(t, ok)
|
require.False(t, ok, "the cleared cookie does not authenticate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSessionSurvivesRestart is the core of ADR-029: a cookie issued by one
|
||||||
|
// process is accepted by a FRESH instance with the same session secret — so a
|
||||||
|
// deploy/pod-restart no longer logs users out (the old in-memory store did).
|
||||||
|
func TestSessionSurvivesRestart(t *testing.T) {
|
||||||
|
f := newFakeIssuer(t)
|
||||||
|
auth1 := newAuth(t, f)
|
||||||
|
cookie := authenticate(t, auth1, f)
|
||||||
|
|
||||||
|
auth2 := newAuth(t, f) // simulate a redeploy: new process, same SessionSecret
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
user, ok := auth2.CurrentUser(req)
|
||||||
|
require.True(t, ok, "a session must survive a restart (stateless signed cookie)")
|
||||||
|
require.Equal(t, testSubject, user.Subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSessionCookieIsPersistent: the cookie carries a positive Max-Age so it
|
||||||
|
// survives the browser/app being closed (a session cookie was dropped on iOS).
|
||||||
|
func TestSessionCookieIsPersistent(t *testing.T) {
|
||||||
|
f := newFakeIssuer(t)
|
||||||
|
auth := newAuth(t, f)
|
||||||
|
cookie := authenticate(t, auth, f)
|
||||||
|
require.Greater(t, cookie.MaxAge, 0, "session cookie must be persistent (Max-Age set)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExpiredSessionRejected(t *testing.T) {
|
func TestExpiredSessionRejected(t *testing.T) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -53,56 +54,44 @@ func (d *DexAuth) unsign(signed string) (string, bool) {
|
|||||||
return value, true
|
return value, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// sessionData is the server-side session record.
|
// sessionClaims is the self-contained session payload carried INSIDE the signed
|
||||||
type sessionData struct {
|
// cookie — there is no server-side session table. This is deliberate (ADR-029):
|
||||||
user web.User
|
// an in-memory store was wiped on every pod restart, logging every user out on
|
||||||
expiry time.Time
|
// each deploy, and a stateless cookie also survives browser-close and works
|
||||||
|
// across replicas. It holds only the identity (subject + email, not secret) and
|
||||||
|
// an absolute expiry; the HMAC tag (sign/unsign) makes it tamper-proof.
|
||||||
|
type sessionClaims struct {
|
||||||
|
Sub string `json:"s"`
|
||||||
|
Email string `json:"e"`
|
||||||
|
Exp int64 `json:"x"` // unix seconds; absolute expiry
|
||||||
}
|
}
|
||||||
|
|
||||||
// sessionStore is an in-memory session table. Single replica at Stage 0, so an
|
// encodeSession produces the signed cookie value for a user with the given expiry.
|
||||||
// in-process map is sufficient; it is safe for concurrent use.
|
func (d *DexAuth) encodeSession(u web.User, exp time.Time) string {
|
||||||
type sessionStore struct {
|
b, _ := json.Marshal(sessionClaims{Sub: u.Subject, Email: u.Email, Exp: exp.Unix()})
|
||||||
mu sync.Mutex
|
return d.sign(base64.RawURLEncoding.EncodeToString(b))
|
||||||
m map[string]sessionData
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSessionStore() *sessionStore { return &sessionStore{m: make(map[string]sessionData)} }
|
// decodeSession verifies the cookie's HMAC, parses the claims, and checks expiry.
|
||||||
|
// It returns the user and the absolute expiry on success.
|
||||||
func (s *sessionStore) put(id string, d sessionData) {
|
func (d *DexAuth) decodeSession(cookieValue string, now time.Time) (web.User, time.Time, bool) {
|
||||||
s.mu.Lock()
|
payload, ok := d.unsign(cookieValue)
|
||||||
defer s.mu.Unlock()
|
|
||||||
s.m[id] = d
|
|
||||||
}
|
|
||||||
|
|
||||||
// get returns the session if present and unexpired; expired entries are evicted.
|
|
||||||
func (s *sessionStore) get(id string, now time.Time) (sessionData, bool) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
d, ok := s.m[id]
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return sessionData{}, false
|
return web.User{}, time.Time{}, false
|
||||||
}
|
}
|
||||||
if !now.Before(d.expiry) {
|
raw, err := base64.RawURLEncoding.DecodeString(payload)
|
||||||
delete(s.m, id)
|
if err != nil {
|
||||||
return sessionData{}, false
|
return web.User{}, time.Time{}, false
|
||||||
}
|
}
|
||||||
return d, true
|
var c sessionClaims
|
||||||
}
|
if err := json.Unmarshal(raw, &c); err != nil {
|
||||||
|
return web.User{}, time.Time{}, false
|
||||||
// refresh slides an existing session's expiry forward; a no-op for unknown ids.
|
|
||||||
func (s *sessionStore) refresh(id string, expiry time.Time) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if d, ok := s.m[id]; ok {
|
|
||||||
d.expiry = expiry
|
|
||||||
s.m[id] = d
|
|
||||||
}
|
}
|
||||||
}
|
exp := time.Unix(c.Exp, 0)
|
||||||
|
if !now.Before(exp) {
|
||||||
func (s *sessionStore) delete(id string) {
|
return web.User{}, time.Time{}, false // expired
|
||||||
s.mu.Lock()
|
}
|
||||||
defer s.mu.Unlock()
|
return web.User{Subject: c.Sub, Email: c.Email}, exp, true
|
||||||
delete(s.m, id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// pendingData holds the nonce bound to an in-flight authorization request.
|
// pendingData holds the nonce bound to an in-flight authorization request.
|
||||||
|
|||||||
Reference in New Issue
Block a user