package 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") } }