package bouncer import ( "context" "testing" "time" "github.com/golang-jwt/jwt/v5" ) // TestAuthTime pins the auth_time claim of plan 07 (P-29, C-CR-02's // framework half): every sign-in mint stamps it equal to iat for both // audiences, every refresh path copies it unchanged across rotations, a // token without it refreshes without it, and VerifyAuthTime keeps the // checks of VerifyClaims and VerifyClaimsAudience. func TestAuthTime(t *testing.T) { const iss = "https://app.test/_user/api/v1/login" const refreshIss = "https://app.test/_user/api/v1/refresh" if ClaimAuthTime != "auth_time" { t.Fatalf("ClaimAuthTime = %q, want auth_time", ClaimAuthTime) } t.Run("mint stamps auth_time equal to iat for both audiences", func(t *testing.T) { for _, aud := range []string{AudienceUser, AudienceBackend} { tok, _, err := MintAudience(secret, "42", iss, time.Hour, aud) if err != nil { t.Fatal(err) } claims := decodeClaims(t, tok, secret) at := claimUnix(t, claims, ClaimAuthTime) iat := claimUnix(t, claims, "iat") if at.Unix() != iat.Unix() { t.Fatalf("%s: auth_time %d != iat %d", aud, at.Unix(), iat.Unix()) } _, got, ok, err := VerifyAuthTime(tok, secret, aud) if err != nil || !ok || got.Unix() != iat.Unix() { t.Fatalf("%s: VerifyAuthTime = %v, %v, %v", aud, got, ok, err) } } tok, _, err := Mint(secret, "42", iss, time.Hour) if err != nil { t.Fatal(err) } if _, _, ok, err := VerifyAuthTime(tok, secret, AudienceUser); err != nil || !ok { t.Fatalf("Mint: ok=%v err=%v", ok, err) } }) signedAt := time.Now().Add(-90 * time.Minute).Truncate(time.Second) users := memUsers{byID: map[uint]*Principal{42: {ID: 42, Backend: true}}} refreshers := []struct { name string audience string refresh func(tok string) (string, error) }{ {"Refresh", AudienceUser, func(tok string) (string, error) { return Refresh(secret, tok, 4*time.Hour, NewMemoryBlacklist(), 0, refreshIss) }}, {"RefreshAudience", AudienceBackend, func(tok string) (string, error) { return RefreshAudience(secret, tok, AudienceBackend, 4*time.Hour, NewMemoryBlacklist(), 0, refreshIss) }}, {"RefreshAudienceFor", AudienceBackend, func(tok string) (string, error) { return RefreshAudienceFor(context.Background(), users, secret, tok, AudienceBackend, 4*time.Hour, NewMemoryBlacklist(), 0, refreshIss) }}, } for _, r := range refreshers { t.Run(r.name+" carries auth_time unchanged across two rotations", func(t *testing.T) { tok, _, err := mintAudience(secret, "42", iss, 30*time.Minute, r.audience, signedAt, signedAt) if err != nil { t.Fatal(err) } prevIat := signedAt for i := 1; i <= 2; i++ { next, err := r.refresh(tok) if err != nil { t.Fatalf("rotation %d: %v", i, err) } _, at, ok, err := VerifyAuthTime(next, secret, r.audience) if err != nil || !ok { t.Fatalf("rotation %d: ok=%v err=%v", i, ok, err) } if at.Unix() != signedAt.Unix() { t.Fatalf("rotation %d: auth_time %d, want %d", i, at.Unix(), signedAt.Unix()) } iat := claimUnix(t, decodeClaims(t, next, secret), "iat") if !iat.After(prevIat) && i == 1 { t.Fatalf("rotation %d: iat %s did not move past %s", i, iat, prevIat) } prevIat = iat tok = next } }) } t.Run("a legacy token without auth_time refreshes without one", func(t *testing.T) { legacy := sign(t, jwt.SigningMethodHS256, jwt.MapClaims{ "sub": "42", "aud": AudienceBackend, "jti": "legacy-backend", "iat": time.Now().Add(-10 * time.Minute).Unix(), "exp": time.Now().Add(time.Minute).Unix(), }, []byte(secret)) for name, refresh := range map[string]func(string) (string, error){ "RefreshAudience": func(tok string) (string, error) { return RefreshAudience(secret, tok, AudienceBackend, time.Hour, NewMemoryBlacklist(), 0, refreshIss) }, "RefreshAudienceFor": func(tok string) (string, error) { return RefreshAudienceFor(context.Background(), users, secret, tok, AudienceBackend, time.Hour, NewMemoryBlacklist(), 0, refreshIss) }, } { next, err := refresh(legacy) if err != nil { t.Fatalf("%s: %v", name, err) } if _, has := decodeClaims(t, next, secret)[ClaimAuthTime]; has { t.Fatalf("%s stamped auth_time on a legacy token", name) } if _, _, ok, err := VerifyAuthTime(next, secret, AudienceBackend); err != nil || ok { t.Fatalf("%s: VerifyAuthTime ok=%v err=%v, want ok=false and no error", name, ok, err) } } user := sign(t, jwt.SigningMethodHS256, jwt.MapClaims{ "sub": "42", "jti": "legacy-user", "iat": time.Now().Add(-10 * time.Minute).Unix(), "exp": time.Now().Add(time.Minute).Unix(), }, []byte(secret)) next, err := Refresh(secret, user, time.Hour, NewMemoryBlacklist(), 0, refreshIss) if err != nil { t.Fatal(err) } if _, has := decodeClaims(t, next, secret)[ClaimAuthTime]; has { t.Fatal("Refresh stamped auth_time on a legacy token") } }) t.Run("VerifyAuthTime refuses what VerifyClaims refuses", func(t *testing.T) { good, _, err := MintAudience(secret, "42", iss, time.Hour, AudienceUser) if err != nil { t.Fatal(err) } if _, _, _, err := VerifyAuthTime(good, "another-secret", AudienceUser); err == nil { t.Fatal("bad signature accepted") } if _, _, _, err := VerifyAuthTime(good, secret, AudienceBackend); err == nil { t.Fatal("wrong audience accepted") } backend, _, err := MintAudience(secret, "42", iss, time.Hour, AudienceBackend) if err != nil { t.Fatal(err) } if _, _, _, err := VerifyAuthTime(backend, secret, AudienceUser); err == nil { t.Fatal("a backend token accepted for the frontend audience") } past := time.Now().Add(-time.Hour) 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") } if _, _, _, err := VerifyAuthTime(good, secret, " "); err == nil { t.Fatal("empty audience accepted") } }) t.Run("a frontend token without aud is accepted as VerifyClaims does", func(t *testing.T) { at := time.Now().Add(-5 * time.Minute).Unix() noAud := sign(t, jwt.SigningMethodHS256, jwt.MapClaims{ "sub": "42", "jti": "no-aud", "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), ClaimAuthTime: at, }, []byte(secret)) if _, _, _, _, err := VerifyClaims(noAud, secret); err != nil { t.Fatalf("VerifyClaims refused the no-aud token: %v", err) } sub, got, ok, err := VerifyAuthTime(noAud, secret, AudienceUser) if err != nil || !ok || sub != "42" || got.Unix() != at { t.Fatalf("VerifyAuthTime(no aud) = %q, %v, %v, %v", sub, got, ok, err) } }) }