Files
summercms/modules/bouncer/auth_time_smoke_test.go
Jakub Zych c14238b94b 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
2026-10-23 16:13:49 +02:00

100 lines
3.4 KiB
Go

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
}