package token_test import ( "context" "errors" "net/http" "net/http/httptest" "strings" "sync" "sync/atomic" "testing" "time" "git.t5de.dev/t5de/t5de-catalog/internal/token" "git.t5de.dev/t5de/t5de-catalog/internal/token/tokentest" ) type hub struct { *httptest.Server mu sync.Mutex keys []*tokentest.Key fetches atomic.Int32 } func newHub(t *testing.T, keys ...*tokentest.Key) *hub { h := &hub{keys: keys} h.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != token.KeysPath { http.NotFound(w, r) return } h.fetches.Add(1) h.mu.Lock() defer h.mu.Unlock() w.Write(tokentest.JWKS(h.keys...)) })) t.Cleanup(h.Close) return h } func (h *hub) setKeys(keys ...*tokentest.Key) { h.mu.Lock() h.keys = keys h.mu.Unlock() } var now = time.Unix(1_800_000_000, 0) func claims(h *hub, typ string, extra map[string]any) map[string]any { c := map[string]any{"iss": h.URL, "typ": typ, "sub": "person1", "iat": now.Unix(), "exp": now.Add(30 * time.Minute).Unix()} for k, v := range extra { if v == nil { delete(c, k) } else { c[k] = v } } return c } func verifier(h *hub, url string) *token.Verifier { v := token.NewVerifier(url, h.Client()) v.SetClock(func() time.Time { return now }) return v } func TestVerify(t *testing.T) { key := tokentest.NewKey("k1") other := tokentest.NewKey("k1") // same ID, different key h := newHub(t, key) v := verifier(h, h.URL+"/") // a trailing slash in HUB_URL is fine c, err := v.Verify(context.Background(), key.Sign(claims(h, token.Upload, map[string]any{"catalog": "cat1", "product": "abcdefghij"})), token.Upload) if err != nil { t.Fatal(err) } if c.Subject != "person1" || c.Catalog != "cat1" || c.Product != "abcdefghij" { t.Fatalf("claims: %+v", c) } c, err = v.Verify(context.Background(), key.Sign(claims(h, token.Access, map[string]any{"adult": true})), token.Access) if err != nil || !c.Adult { t.Fatalf("access: %+v, %v", c, err) } // Revision tokens don't expire. if _, err := v.Verify(context.Background(), key.Sign(claims(h, token.Revision, map[string]any{"exp": nil})), token.Revision); err != nil { t.Fatal(err) } bad := map[string]string{ "wrong type": key.Sign(claims(h, token.Access, nil)), "wrong issuer": key.Sign(claims(h, token.Upload, map[string]any{"iss": "https://evil.example"})), "expired": key.Sign(claims(h, token.Upload, map[string]any{"exp": now.Add(-2 * time.Minute).Unix()})), "no expiry": key.Sign(claims(h, token.Upload, map[string]any{"exp": nil})), "not yet": key.Sign(claims(h, token.Upload, map[string]any{"nbf": now.Add(time.Hour).Unix()})), "future iat": key.Sign(claims(h, token.Upload, map[string]any{"iat": now.Add(time.Hour).Unix()})), "other key": other.Sign(claims(h, token.Upload, nil)), "alg none": key.SignWithHeader(map[string]any{"alg": "none", "kid": "k1"}, claims(h, token.Upload, nil)), "alg HS256": key.SignWithHeader(map[string]any{"alg": "HS256", "kid": "k1"}, claims(h, token.Upload, nil)), "crit": key.SignWithHeader(map[string]any{"alg": "EdDSA", "kid": "k1", "crit": []string{"x"}}, claims(h, token.Upload, nil)), "two parts": "a.b", "garbage": "not a token at all", "empty signature": strings.Join(strings.Split(key.Sign(claims(h, token.Upload, nil)), ".")[:2], ".") + ".", } for name, tok := range bad { if _, err := v.Verify(context.Background(), tok, token.Upload); !errors.Is(err, token.ErrInvalid) { t.Errorf("%s: got %v, want ErrInvalid", name, err) } } // A tampered payload fails the signature. parts := strings.Split(key.Sign(claims(h, token.Access, map[string]any{"adult": false})), ".") forged := key.Sign(claims(h, token.Access, map[string]any{"adult": true})) tampered := parts[0] + "." + strings.Split(forged, ".")[1] + "." + parts[2] if _, err := v.Verify(context.Background(), tampered, token.Access); !errors.Is(err, token.ErrInvalid) { t.Errorf("tampered: %v", err) } } func TestExpiryLeeway(t *testing.T) { key := tokentest.NewKey("k1") h := newHub(t, key) v := verifier(h, h.URL) tok := key.Sign(claims(h, token.Upload, map[string]any{"exp": now.Add(-30 * time.Second).Unix()})) if _, err := v.Verify(context.Background(), tok, token.Upload); err != nil { t.Fatalf("within leeway: %v", err) } } func TestKeyRotation(t *testing.T) { k1, k2 := tokentest.NewKey("k1"), tokentest.NewKey("k2") h := newHub(t, k1) clock := now v := token.NewVerifier(h.URL, h.Client()) v.SetClock(func() time.Time { return clock }) for range 3 { if _, err := v.Verify(context.Background(), k1.Sign(claims(h, token.Upload, nil)), token.Upload); err != nil { t.Fatal(err) } } if n := h.fetches.Load(); n != 1 { t.Fatalf("keys fetched %d times, want 1 (cached)", n) } // An unknown kid right after a fetch isn't fetched again (rate limit). h.setKeys(k1, k2) for range 5 { if _, err := v.Verify(context.Background(), k2.Sign(claims(h, token.Upload, nil)), token.Upload); err == nil { t.Fatal("unknown key accepted before refetch") } } if n := h.fetches.Load(); n != 1 { t.Fatalf("keys fetched %d times, want 1 (rate-limited)", n) } // Later, an unknown kid fetches the keys again. clock = clock.Add(token.RefetchInterval) if _, err := v.Verify(context.Background(), k2.Sign(claims(h, token.Upload, nil)), token.Upload); err != nil { t.Fatalf("rotated key: %v", err) } if n := h.fetches.Load(); n != 2 { t.Fatalf("keys fetched %d times, want 2", n) } } func TestParseKeys(t *testing.T) { k := tokentest.NewKey("good") data := []byte(`{"keys":[ {"kty":"RSA","kid":"rsa","n":"x","e":"AQAB"}, {"kty":"OKP","crv":"X25519","kid":"x","x":"` + strings.Repeat("A", 43) + `"}, {"kty":"OKP","crv":"Ed25519","kid":"short","x":"AAAA"}, {"kty":"OKP","crv":"Ed25519","kid":"enc","use":"enc","x":"` + strings.Repeat("A", 43) + `"} ]}`) keys, err := token.ParseKeys(data) if err != nil || len(keys) != 0 { t.Fatalf("got %v, %v; want no keys", keys, err) } keys, err = token.ParseKeys(tokentest.JWKS(k)) if err != nil || !keys["good"].Equal(k.Public) { t.Fatalf("got %v, %v", keys, err) } }