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
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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. |
|
||||
|
||||
99
modules/bouncer/auth_time_smoke_test.go
Normal file
99
modules/bouncer/auth_time_smoke_test.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user