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 }