Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379package oidc
import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "math/big" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time"
"tangled.org/core/spindle/pemkey/pemkeytest")
const ( testIssuer = "https://tokens.spindle.tangled.sh" testAudience = "//iam.googleapis.com/projects/1/locations/global/workloadIdentityPools/tangled/providers/spindle")
func newTestIssuer(t *testing.T, keyID string) (*Issuer, *ecdsa.PrivateKey) { t.Helper() path, key := pemkeytest.WriteP256(t) issuer, err := New(Options{ Issuer: testIssuer, KeyPath: path, KeyID: keyID, }) if err != nil { t.Fatal(err) } return issuer, key}
func TestNewRejectsBadConfiguration(t *testing.T) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatal(err) } p256 := pemkeytest.Write(t, key, true) secp256k1, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) if err != nil { t.Fatal(err) }
tests := []struct { name string opts Options }{ {"relative issuer", Options{Issuer: "tokens.spindle.tangled.sh", KeyPath: p256}}, {"non-http issuer", Options{Issuer: "ftp://tokens.spindle.tangled.sh", KeyPath: p256}}, {"plain http issuer", Options{Issuer: "http://tokens.spindle.tangled.sh", KeyPath: p256}}, {"issuer with a path", Options{Issuer: "https://tokens.spindle.tangled.sh/issuer", KeyPath: p256}}, {"issuer with a query", Options{Issuer: "https://tokens.spindle.tangled.sh?x=1", KeyPath: p256}}, {"missing key file", Options{Issuer: testIssuer, KeyPath: filepath.Join(t.TempDir(), "absent.pem")}}, {"wrong curve", Options{Issuer: testIssuer, KeyPath: pemkeytest.Write(t, secp256k1, false)}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if _, err := New(tt.opts); err == nil { t.Fatal("New accepted invalid issuer configuration") } }) }}
func TestJWT(t *testing.T) { issuer, key := newTestIssuer(t, "") if got := issuer.KeyID(); got == "" { t.Fatal("derived key id is empty") } token, err := issuer.Mint(Run{ Repository: "did:plc:boltless", Ref: "refs/heads/main", SHA: "1111111111111111111111111111111111111111", Actor: "did:plc:pusher", }, 10*time.Minute, testAudience) if err != nil { t.Fatal(err) }
parts := strings.Split(token, ".") if len(parts) != 3 { t.Fatalf("token has %d parts, want 3", len(parts)) }
var header tokenHeader decode(t, parts[0], &header) if header.Algorithm != "ES256" { t.Errorf("alg = %q, want ES256", header.Algorithm) } if header.KeyID != issuer.KeyID() { t.Errorf("kid = %q, want %q", header.KeyID, issuer.KeyID()) }
payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { t.Fatal(err) } t.Logf("minted claims: %s", payload)
var got claims decode(t, parts[1], &got) want := claims{ Issuer: testIssuer, Audience: testAudience, Subject: "did:plc:boltless:refs/heads/main", Repository: "did:plc:boltless", Ref: "refs/heads/main", Actor: "did:plc:pusher", SHA: "1111111111111111111111111111111111111111", } if got.Issuer != want.Issuer || got.Audience != want.Audience || got.Subject != want.Subject || got.Repository != want.Repository || got.Ref != want.Ref || got.Actor != want.Actor || got.SHA != want.SHA { t.Errorf("claims = %+v, want %+v", got, want) } if got.ExpiresAt-got.IssuedAt != int64(minimumTokenLifetime/time.Second) { t.Errorf("lifetime = %ds, want %ds", got.ExpiresAt-got.IssuedAt, int64(minimumTokenLifetime/time.Second)) } if got.NotBefore != got.IssuedAt { t.Errorf("nbf = %d, want iat = %d", got.NotBefore, got.IssuedAt) } if got.ExpiresAt <= time.Now().Unix() { t.Error("token is already expired") } if len(got.JTI) != 32 { t.Errorf("jti = %q, want a 128-bit nonce", got.JTI) }
// the published key must verify the signature the token carries var set keySet body := get(t, issuer.ServeJWKS, JWKSPath) if err := json.Unmarshal(body, &set); err != nil { t.Fatal(err) } if len(set.Keys) != 1 { t.Fatalf("jwks has %d keys, want 1", len(set.Keys)) } jwk := set.Keys[0] if jwk.KeyType != "EC" || jwk.Curve != "P-256" || jwk.Alg != "ES256" || jwk.Use != "sig" { t.Errorf("jwk = %+v, want an EC P-256 signing key", jwk) } if jwk.KeyID != issuer.KeyID() { t.Errorf("jwk kid = %q, want %q", jwk.KeyID, issuer.KeyID()) } if want := thumbprint(jwk); want != issuer.KeyID() { t.Errorf("derived kid = %q, want the RFC 7638 thumbprint %q", issuer.KeyID(), want) } assertSigned(t, jwk, parts[0]+"."+parts[1], parts[2], key.PublicKey)}
func TestMintLifetimeFollowsTheWorkflowTimeBudget(t *testing.T) { issuer, _ := newTestIssuer(t, "") run := Run{ Repository: "did:plc:boltless", SHA: "1111111111111111111111111111111111111111", } for _, tt := range []struct { name, budget, want string }{ {"short workflow keeps the minimum", "10m", "15m"}, {"long workflow keeps its budget", "3h", "3h"}, {"excessive workflow stays bounded", "24h", "6h"}, } { t.Run(tt.name, func(t *testing.T) { budget, err := time.ParseDuration(tt.budget) if err != nil { t.Fatal(err) } token, err := issuer.Mint(run, budget, testAudience) if err != nil { t.Fatal(err) } var got claims decode(t, strings.Split(token, ".")[1], &got) want, _ := time.ParseDuration(tt.want) if lifetime := time.Duration(got.ExpiresAt-got.IssuedAt) * time.Second; lifetime != want { t.Fatalf("lifetime = %s, want %s for a %s workflow budget", lifetime, want, budget) } }) }}
func TestMintRefusesAnUnrecordableRun(t *testing.T) { issuer, _ := newTestIssuer(t, "") good := Run{ Repository: "did:plc:boltless", Ref: "refs/heads/main", SHA: "1111111111111111111111111111111111111111", Actor: "did:plc:pusher", }
tests := []struct { name string run Run }{ {"no repository", Run{Ref: good.Ref, SHA: good.SHA}}, {"repository is not a did", Run{Repository: "boltless", Ref: good.Ref, SHA: good.SHA}}, {"no commit", Run{Repository: good.Repository, Ref: good.Ref}}, {"ref is not a refname", Run{Repository: good.Repository, Ref: "refs/heads/main:evil", SHA: good.SHA}}, {"ref is a bare branch", Run{Repository: good.Repository, Ref: "main", SHA: good.SHA}}, {"actor is not a did", Run{Repository: good.Repository, Ref: good.Ref, SHA: good.SHA, Actor: "mallory"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := issuer.Mint(tt.run, 10*time.Minute, testAudience) if err == nil { t.Fatal("Mint signed a run the server could not have recorded") } if !errors.Is(err, ErrUnrecordableRun) { t.Fatalf("err = %v, want ErrUnrecordableRun so callers withhold the token instead of failing the run", err) } }) }
// a run without a resolved ref is legitimate: the claim is left empty token, err := issuer.Mint(Run{ Repository: good.Repository, SHA: good.SHA, Actor: good.Actor, }, 10*time.Minute, testAudience) if err != nil { t.Fatal(err) } var got claims decode(t, strings.Split(token, ".")[1], &got) if got.Ref != "" || got.Subject != "did:plc:boltless:" { t.Fatalf("ref = %q subject = %q, want an empty ref claim", got.Ref, got.Subject) }}
func TestMintWithoutAudience(t *testing.T) { issuer, _ := newTestIssuer(t, "") if _, err := issuer.Mint(Run{ Repository: "did:plc:boltless", Ref: "refs/heads/main", SHA: "1111111111111111111111111111111111111111", }, 10*time.Minute, ""); err == nil { t.Fatal("Mint signed a token with an empty audience") }}
func TestMintRejectsAnAudienceThatWouldNotRoundTrip(t *testing.T) { issuer, _ := newTestIssuer(t, "") for _, audience := range []string{"sig\u202estore", "vault\u00a0.example", "sigst\xff\xfdo", "space inside", "line\nbreak"} { if _, err := issuer.Mint(Run{ Repository: "did:plc:boltless", Ref: "refs/heads/main", SHA: "1111111111111111111111111111111111111111", }, 10*time.Minute, audience); err == nil { t.Fatalf("Mint signed an audience %q that differs from what was declared", audience) } }}
func TestMintNonceIsUniquePerRun(t *testing.T) { issuer, _ := newTestIssuer(t, "") run := Run{ Repository: "did:plc:boltless", Ref: "refs/heads/main", SHA: "1111111111111111111111111111111111111111", }
seen := map[string]bool{} for range 3 { token, err := issuer.Mint(run, 10*time.Minute, testAudience) if err != nil { t.Fatal(err) } var got claims decode(t, strings.Split(token, ".")[1], &got) if seen[got.JTI] { t.Fatalf("jti %q repeated for the same run; a retry must not replay a token", got.JTI) } seen[got.JTI] = true }}
func TestMintedKeyIDOverridesTheThumbprint(t *testing.T) { issuer, _ := newTestIssuer(t, "tokens-2026-09") token, err := issuer.Mint(Run{ Repository: "did:plc:boltless", SHA: "1111111111111111111111111111111111111111", }, 10*time.Minute, testAudience) if err != nil { t.Fatal(err) } var header tokenHeader decode(t, strings.Split(token, ".")[0], &header) if header.KeyID != "tokens-2026-09" { t.Fatalf("kid = %q, want the configured key id", header.KeyID) }}
func TestConfigurationDocument(t *testing.T) { issuer, _ := newTestIssuer(t, "") var doc struct { Issuer string `json:"issuer"` JWKSURI string `json:"jwks_uri"` Algs []string `json:"id_token_signing_alg_values_supported"` } if err := json.Unmarshal(get(t, issuer.ServeConfiguration, ConfigurationPath), &doc); err != nil { t.Fatal(err) } if doc.Issuer != testIssuer { t.Errorf("issuer = %q, want %q", doc.Issuer, testIssuer) } if want := testIssuer + JWKSPath; doc.JWKSURI != want { t.Errorf("jwks_uri = %q, want %q", doc.JWKSURI, want) } if len(doc.Algs) != 1 || doc.Algs[0] != "ES256" { t.Errorf("algs = %v, want [ES256]", doc.Algs) }}
func get(t *testing.T, handler http.HandlerFunc, path string) []byte { t.Helper() rec := httptest.NewRecorder() handler(rec, httptest.NewRequest(http.MethodGet, path, nil)) if rec.Code != http.StatusOK { t.Fatalf("GET %s = %d, want 200", path, rec.Code) } if got := rec.Header().Get("Content-Type"); got != "application/json" { t.Fatalf("GET %s content type = %q, want application/json", path, got) } if got := rec.Header().Get("Cache-Control"); got != cacheMaxAge { t.Fatalf("GET %s cache control = %q, want %q", path, got, cacheMaxAge) } return rec.Body.Bytes()}
func decode(t *testing.T, part string, into any) { t.Helper() raw, err := base64.RawURLEncoding.DecodeString(part) if err != nil { t.Fatal(err) } if err := json.Unmarshal(raw, into); err != nil { t.Fatal(err) }}
func assertSigned(t *testing.T, key jsonWebKey, signingInput, signature string, want ecdsa.PublicKey) { t.Helper() x, err := base64.RawURLEncoding.DecodeString(key.X) if err != nil { t.Fatal(err) } y, err := base64.RawURLEncoding.DecodeString(key.Y) if err != nil { t.Fatal(err) } pub := ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y)} if pub.X.Cmp(want.X) != 0 || pub.Y.Cmp(want.Y) != 0 { t.Fatal("published key is not the signing key") }
sig, err := base64.RawURLEncoding.DecodeString(signature) if err != nil { t.Fatal(err) } if len(sig) != 64 { t.Fatalf("signature is %d bytes, want the 64 of a JWS ES256 signature", len(sig)) } digest := sha256.Sum256([]byte(signingInput)) r := new(big.Int).SetBytes(sig[:32]) s := new(big.Int).SetBytes(sig[32:]) if !ecdsa.Verify(&pub, digest[:], r, s) { t.Fatal("signature does not verify against the published key") }}