- check-phase19.7.sh: workspace_check (framework to this checkout, the OAuth server plugin's user plugin to USER_PLUGIN) in the plugins, consumers and docker stages; consumer_with_user_plugin builds fonoteka.go and sm-bm-app against USER_PLUGIN through a scratch go.work; demo_pins accepts unreleased commits its canonical checkout holds and refuses any drift with PHASE197_RELEASED=1; self-test cases for each, one PASS line each - TestSessionReaper pins A-CR-01, A-IN-01 and A-IN-06 (memory and Redis stores, store errors, the GET stream re-check, the ephemeral label) - TestAuthTime pins auth_time at mint and through every refresh path - GO_TESTS and GO_TAGGED_TESTS require them with the plan 06/07 smokes
183 lines
6.6 KiB
Go
183 lines
6.6 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|