From c14238b94baafa28f292e82292ef0255c0090631 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 23 Oct 2026 16:13:49 +0200 Subject: [PATCH] feat(bouncer): stamp auth_time at sign-in and carry it unchanged through refresh (C-CR-02) - MintAudience stamps auth_time equal to iat; refreshAudience copies the presented token's value and never renews it (absent stays absent) - bouncer.ClaimAuthTime and bouncer.VerifyAuthTime for plugins, with the same signature, audience and expiry checks as VerifyClaims - TestAuthTimeSmoke traces mint, refresh and read --- docs/services/authentication.md | 6 ++ modules/bouncer/README.md | 3 + modules/bouncer/auth_time_smoke_test.go | 99 +++++++++++++++++++++++++ modules/bouncer/jwt.go | 64 ++++++++++++---- modules/bouncer/mint.go | 24 +++++- modules/bouncer/refresh.go | 5 +- 6 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 modules/bouncer/auth_time_smoke_test.go diff --git a/docs/services/authentication.md b/docs/services/authentication.md index baf73909..1c9a5eef 100644 --- a/docs/services/authentication.md +++ b/docs/services/authentication.md @@ -58,6 +58,12 @@ Revoked token IDs are kept in a `bouncer.BlacklistStore`: Logging out is blacklisting the token's `jti`. Setting a user's `TokensValidAfter` to now revokes all their tokens at once, for example after a password change. +### Sign-in time (auth_time) + +Every token `bouncer.Mint` or `bouncer.MintAudience` signs carries an `auth_time` claim (`bouncer.ClaimAuthTime`, seconds since the epoch) equal to its `iat`, because every direct mint is a sign-in: a password login, an activation, a social login or an admin login. A refresh copies the presented token's `auth_time` into the rotated token unchanged, and a token minted before the claim existed is refreshed without one. + +A check that asks for a fresh sign-in, for example before linking two accounts, compares `auth_time`, never `iat`: a refresh renews `iat` without a password, so a weeks-old session would pass an `iat` check. Read the claim with `bouncer.VerifyAuthTime`, which runs the same signature, audience and expiry checks as `bouncer.VerifyClaims` and reports `ok` false when the token has no `auth_time`; refuse such a token rather than treating it as fresh. + ## Cookie transport A browser frontend can keep the token in an httpOnly cookie instead of JavaScript-readable storage. The cookie attributes come from one block, `auth.cookie.*`, which `bouncer.LoadCookieConfig` reads into a `bouncer.CookieConfig` at boot: diff --git a/modules/bouncer/README.md b/modules/bouncer/README.md index 36cde6b1..e7653da3 100644 --- a/modules/bouncer/README.md +++ b/modules/bouncer/README.md @@ -24,6 +24,7 @@ Use cases: - Token minting with `bouncer.Mint` (frontend audience) and `bouncer.MintAudience` (any audience), each returning the signed token and its random jti. - Verification with HS256 pinned and `exp` and `sub` required: `bouncer.Verify` and `bouncer.VerifyClaims` accept frontend tokens, including legacy tokens with no audience claim; `bouncer.VerifyClaimsAudience` requires an explicit audience, so a backend token cannot pass a frontend check and the other way round. +- Sign-in time: every `bouncer.Mint` and `bouncer.MintAudience` token carries the `auth_time` claim (`bouncer.ClaimAuthTime`) equal to its `iat`, and every refresh copies it unchanged, so a refreshed token keeps the time of the sign-in that started the session. `bouncer.VerifyAuthTime` reads it with the same checks as `bouncer.VerifyClaims`. - Refresh with `bouncer.Refresh`, `bouncer.RefreshAudience` and `bouncer.RefreshAudienceFor`: an expired token can be reissued while its `iat` is inside the refresh window; the old jti is blacklisted after a grace period. `bouncer.RefreshAudienceFor` also reloads the user and refuses deleted users and tokens issued before `bouncer.Principal.TokensValidAfter`, reporting `bouncer.ErrSubjectRejected`. - JWT guards: `bouncer.NewJWTGuard` (frontend) and `bouncer.NewBackendJWTGuard` (admin audience, optional custom 401 writer) read the bearer token first and then any configured cookies, load the user through a `bouncer.UserProvider`, check the blacklist and the `bouncer.Principal.TokensValidAfter` cutoff, and write a JSON 401 body (`{"error":true,"message":...}`, with `Cache-Control: no-cache, private`) on failure. - Named guard registry: `bouncer.Registry.Register` accepts any `bouncer.Guard` or `bouncer.CredentialGuard`; `bouncer.Registry.Middleware` derives middleware that stores the principal (and credential, if any) on the context. Guards that do not implement `bouncer.UnauthorizedWriter` let unauthenticated requests through so later middleware can decide. @@ -146,6 +147,8 @@ func RefreshRoute(cfg bouncer.CookieConfigSource, users bouncer.UserProvider, se | `bouncer.Verify` | Verifies a frontend token and returns its subject. | | `bouncer.VerifyClaims` | `bouncer.Verify` plus `iat`, `exp` and `jti`. | | `bouncer.VerifyClaimsAudience` | `bouncer.VerifyClaims` with a required audience. | +| `bouncer.ClaimAuthTime` | The `auth_time` claim name: the sign-in time, set by every mint and copied unchanged by refresh. | +| `bouncer.VerifyAuthTime` | Verifies a token like `bouncer.VerifyClaims` (`bouncer.AudienceUser`) or `bouncer.VerifyClaimsAudience` (other audiences) and returns its subject and `auth_time`; `ok` is false when the claim is absent. | | `bouncer.Refresh` | Reissues a frontend token inside the refresh window and blacklists the old jti. | | `bouncer.RefreshAudience` | Refresh for a token that carries the given audience. | | `bouncer.VerifyRefreshableClaimsAudience` | Verifies signature and audience without checking `exp`, while the refresh window is open; for revoking a refreshable token on logout. | diff --git a/modules/bouncer/auth_time_smoke_test.go b/modules/bouncer/auth_time_smoke_test.go new file mode 100644 index 00000000..b2e7534a --- /dev/null +++ b/modules/bouncer/auth_time_smoke_test.go @@ -0,0 +1,99 @@ +package bouncer + +import ( + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// TestAuthTimeSmoke is the tracer for the auth_time claim: a sign-in mint +// stamps it, a refresh carries it over unchanged while renewing iat, a token +// without it refreshes into one without it, and VerifyAuthTime keeps the +// signature, audience and expiry checks of VerifyClaims. +func TestAuthTimeSmoke(t *testing.T) { + const iss = "https://app.test/_user/api/v1/login" + const refreshIss = "https://app.test/_user/api/v1/refresh" + + // 1. A sign-in mint carries auth_time equal to iat. + signedIn, _, err := Mint(secret, "42", iss, time.Hour) + if err != nil { + t.Fatal(err) + } + sub, authTime, ok, err := VerifyAuthTime(signedIn, secret, AudienceUser) + if err != nil || !ok || sub != "42" { + t.Fatalf("VerifyAuthTime(minted) = %q, %v, %v, %v", sub, authTime, ok, err) + } + iat := claimUnix(t, decodeClaims(t, signedIn, secret), "iat") + if authTime.Unix() != iat.Unix() { + t.Fatalf("auth_time %d != iat %d", authTime.Unix(), iat.Unix()) + } + if _, _, ok, err := VerifyAuthTime(mustMintBackend(t), secret, AudienceBackend); err != nil || !ok { + t.Fatalf("backend mint: ok=%v err=%v", ok, err) + } + + // 2. A token signed in an hour ago refreshes into a token with a new + // iat and the original auth_time. + past := time.Now().Add(-time.Hour) + old, _, err := mintAudience(secret, "42", iss, 30*time.Minute, AudienceUser, past, past) + if err != nil { + t.Fatal(err) + } + next, err := Refresh(secret, old, 2*time.Hour, NewMemoryBlacklist(), 0, refreshIss) + if err != nil { + t.Fatal(err) + } + _, nextAuth, ok, err := VerifyAuthTime(next, secret, AudienceUser) + if err != nil || !ok { + t.Fatalf("VerifyAuthTime(refreshed): ok=%v err=%v", ok, err) + } + if nextAuth.Unix() != past.Unix() { + t.Fatalf("refreshed auth_time = %d, want %d", nextAuth.Unix(), past.Unix()) + } + if nextIat := claimUnix(t, decodeClaims(t, next, secret), "iat"); !nextIat.After(past) { + t.Fatalf("refreshed iat %s not after %s", nextIat, past) + } + + // 3. A token without auth_time refreshes into a token without it. + legacy := sign(t, jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "42", + "aud": AudienceUser, + "jti": "legacy", + "iat": time.Now().Add(-10 * time.Minute).Unix(), + "exp": time.Now().Add(-time.Minute).Unix(), + }, []byte(secret)) + rotated, err := Refresh(secret, legacy, time.Hour, NewMemoryBlacklist(), 0, refreshIss) + if err != nil { + t.Fatal(err) + } + if _, _, ok, err := VerifyAuthTime(rotated, secret, AudienceUser); err != nil || ok { + t.Fatalf("legacy refresh: ok=%v err=%v, want ok=false and no error", ok, err) + } + if _, has := decodeClaims(t, rotated, secret)[ClaimAuthTime]; has { + t.Fatal("legacy refresh stamped auth_time") + } + + // 4. Wrong audience, bad signature and past exp are errors. + if _, _, _, err := VerifyAuthTime(signedIn, secret, AudienceBackend); err == nil { + t.Fatal("frontend token accepted for the backend audience") + } + if _, _, _, err := VerifyAuthTime(signedIn, "other-secret", AudienceUser); err == nil { + t.Fatal("token accepted with a different secret") + } + expired, _, err := mintAudience(secret, "42", iss, time.Minute, AudienceUser, past, past) + if err != nil { + t.Fatal(err) + } + if _, _, _, err := VerifyAuthTime(expired, secret, AudienceUser); err == nil { + t.Fatal("expired token accepted") + } +} + +func mustMintBackend(t *testing.T) string { + t.Helper() + tok, _, err := MintAudience(secret, "7", "https://app.test/backend/login", time.Hour, AudienceBackend) + if err != nil { + t.Fatal(err) + } + return tok +} diff --git a/modules/bouncer/jwt.go b/modules/bouncer/jwt.go index b0f60ee6..f2c531a9 100644 --- a/modules/bouncer/jwt.go +++ b/modules/bouncer/jwt.go @@ -203,24 +203,34 @@ func VerifyClaimsAudience(tokenString, secret, audience string) (sub string, iat return verifyClaims(tokenString, secret, audience) } -func verifyClaims(tokenString, secret, audience string) (sub string, iat, exp time.Time, jti string, err error) { - if strings.TrimSpace(secret) == "" { - return "", time.Time{}, time.Time{}, "", fmt.Errorf("bouncer: jwt secret is empty") +// VerifyAuthTime verifies a token exactly as VerifyClaims does for +// AudienceUser (a frontend token without aud is accepted) and as +// VerifyClaimsAudience does for any other audience: HS256 signature, +// audience, a present and unexpired exp, and a subject. It returns the +// subject and the auth_time claim (ClaimAuthTime), the time of the sign-in +// that started the session. ok is false when the token carries no numeric +// auth_time, for example one minted before the claim existed. A "fresh +// sign-in" check compares authTime, never iat, because a refresh renews iat. +func VerifyAuthTime(tokenString, secret, audience string) (sub string, authTime time.Time, ok bool, err error) { + if strings.TrimSpace(audience) == "" { + return "", time.Time{}, false, fmt.Errorf("bouncer: jwt audience is empty") } - parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired()) - claims := jwt.MapClaims{} - _, err = parser.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) { - return []byte(secret), nil - }) + expected := audience + if audience == AudienceUser { + expected = "" + } + claims, sub, err := parseVerified(tokenString, secret, expected) if err != nil { - return "", time.Time{}, time.Time{}, "", mapJWTError(err) + return "", time.Time{}, false, err } - if !frontendAudienceOK(claims, audience) { - return "", time.Time{}, time.Time{}, "", errors.New(msgBadSignature) - } - sub = subject(claims) - if sub == "" { - return "", time.Time{}, time.Time{}, "", errors.New(msgRequiredClaims) + authTime, ok = claimTime(claims, ClaimAuthTime) + return sub, authTime, ok, nil +} + +func verifyClaims(tokenString, secret, audience string) (sub string, iat, exp time.Time, jti string, err error) { + claims, sub, err := parseVerified(tokenString, secret, audience) + if err != nil { + return "", time.Time{}, time.Time{}, "", err } iat, _ = claimTime(claims, "iat") exp, _ = claimTime(claims, "exp") @@ -228,6 +238,30 @@ func verifyClaims(tokenString, secret, audience string) (sub string, iat, exp ti return sub, iat, exp, jti, nil } +// parseVerified runs the shared verification of verifyClaims and returns +// the claims and the subject. audience "" accepts a frontend token with no +// aud or with AudienceUser; any other value requires that audience. +func parseVerified(tokenString, secret, audience string) (jwt.MapClaims, string, error) { + if strings.TrimSpace(secret) == "" { + return nil, "", fmt.Errorf("bouncer: jwt secret is empty") + } + parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired()) + claims := jwt.MapClaims{} + if _, err := parser.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) { + return []byte(secret), nil + }); err != nil { + return nil, "", mapJWTError(err) + } + if !frontendAudienceOK(claims, audience) { + return nil, "", errors.New(msgBadSignature) + } + sub := subject(claims) + if sub == "" { + return nil, "", errors.New(msgRequiredClaims) + } + return claims, sub, nil +} + // frontendAudienceOK accepts a missing audience when expected is empty // (legacy frontend tokens) and otherwise requires expected. func frontendAudienceOK(claims jwt.MapClaims, expected string) bool { diff --git a/modules/bouncer/mint.go b/modules/bouncer/mint.go index 59c65970..8e7c4940 100644 --- a/modules/bouncer/mint.go +++ b/modules/bouncer/mint.go @@ -21,9 +21,16 @@ const ( AudienceBackend = "backend" ) +// ClaimAuthTime is the JWT claim holding the time of the sign-in that started +// the session (OIDC Core's auth_time, NumericDate seconds). Every direct mint +// is a sign-in and stamps it equal to iat; a refresh copies it unchanged, so +// a "fresh sign-in" check compares auth_time, never iat. +const ClaimAuthTime = "auth_time" + type registeredClaims struct { jwt.RegisteredClaims - Prv string `json:"prv,omitempty"` + Prv string `json:"prv,omitempty"` + AuthTime *jwt.NumericDate `json:"auth_time,omitempty"` } // Mint signs a frontend-audience HS256 token. iss is the minting endpoint URL. @@ -33,8 +40,17 @@ func Mint(secret, sub, issuerURL string, ttl time.Duration) (string, string, err } // MintAudience signs an HS256 token for audience. Frontend tokens keep the PHP -// prv lock-subject; backend tokens omit it. +// prv lock-subject; backend tokens omit it. Every MintAudience call is a +// sign-in, so the token's auth_time (ClaimAuthTime) equals its iat. func MintAudience(secret, sub, issuerURL string, ttl time.Duration, audience string) (string, string, error) { + now := time.Now() + return mintAudience(secret, sub, issuerURL, ttl, audience, now, now) +} + +// mintAudience signs the token issued at now. authTime, when non-zero, +// becomes the auth_time claim; a zero authTime mints a token without one. A +// sign-in passes the same instant as now and authTime, so auth_time equals iat. +func mintAudience(secret, sub, issuerURL string, ttl time.Duration, audience string, now, authTime time.Time) (string, string, error) { if strings.TrimSpace(secret) == "" { return "", "", fmt.Errorf("bouncer: jwt secret is empty") } @@ -45,7 +61,6 @@ func MintAudience(secret, sub, issuerURL string, ttl time.Duration, audience str if err != nil { return "", "", err } - now := time.Now() prv := "" if audience == AudienceUser { prv = prvHash @@ -62,6 +77,9 @@ func MintAudience(secret, sub, issuerURL string, ttl time.Duration, audience str }, Prv: prv, } + if !authTime.IsZero() { + claims.AuthTime = jwt.NewNumericDate(authTime) + } signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) if err != nil { return "", "", err diff --git a/modules/bouncer/refresh.go b/modules/bouncer/refresh.go index cdb4efd5..a0a8472e 100644 --- a/modules/bouncer/refresh.go +++ b/modules/bouncer/refresh.go @@ -136,7 +136,10 @@ func refreshAudience(secret, tokenString, audience string, allowMissing bool, re return "", err } } - next, _, err := MintAudience(secret, sub, issuerURL, ttl, audience) + // The rotated token keeps the sign-in time of the presented one: refresh + // is not a sign-in. A token without auth_time stays without it. + authTime, _ := claimTime(claims, ClaimAuthTime) + next, _, err := mintAudience(secret, sub, issuerURL, ttl, audience, time.Now(), authTime) if err != nil { return "", err }