CI / build (push) Successful in 54s
A Go server that takes product uploads with hub upload tokens, checks them by t5de-api's pack rules, stores files by sha256, registers revisions with the hub (POST /api/v1/catalog/revisions), and serves files with immutable caching; files used only by adult revisions need an adult access token. Contract: t5de-hub's docs/products.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
183 lines
6.1 KiB
Go
183 lines
6.1 KiB
Go
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)
|
|
}
|
|
}
|