package spindle import ( "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "io" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "tangled.org/core/api/tangled" "tangled.org/core/idresolver" "tangled.org/core/notifier" "tangled.org/core/spindle/config" "tangled.org/core/spindle/db" "tangled.org/core/spindle/models" "tangled.org/core/spindle/observability" "tangled.org/core/spindle/oidc" "tangled.org/core/spindle/secrets" ) const ( oidcTestIssuerURL = "https://tokens.spindle.tangled.sh" oidcTestAudience = "//iam.googleapis.com/projects/tangled-prod/locations/global/workloadIdentityPools/ci/providers/spindle" oidcTestSha = "1111111111111111111111111111111111111111" oidcTestRepoDid = "did:plc:boltless" oidcTestActor = "did:plc:pusher" ) func oidcTestIssuer(t *testing.T) *oidc.Issuer { t.Helper() key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatal(err) } der, err := x509.MarshalECPrivateKey(key) if err != nil { t.Fatal(err) } path := filepath.Join(t.TempDir(), "oidc-key.pem") block := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) if err := os.WriteFile(path, block, 0o600); err != nil { t.Fatal(err) } issuer, err := oidc.New(oidc.Options{Issuer: oidcTestIssuerURL, KeyPath: path}) if err != nil { t.Fatal(err) } return issuer } func oidcTestPushJob(workflowYaml string) *db.JobRow { repoDid, actor := oidcTestRepoDid, oidcTestActor return &db.JobRow{ Id: 42, PipelineId: models.PipelineId("3mrkp6iz6os2o"), RepoDid: oidcTestRepoDid, Tpl: tangled.Pipeline{ TriggerMetadata: &tangled.Pipeline_TriggerMetadata{ Kind: "push", Actor: &actor, Push: &tangled.Pipeline_PushTriggerData{Ref: "refs/heads/main", NewSha: oidcTestSha, OldSha: strings.Repeat("0", 40)}, Repo: &tangled.Pipeline_TriggerRepo{ Knot: "knot.test", Did: "did:plc:owner", RepoDid: &repoDid, DefaultBranch: "main", }, }, Workflows: []*tangled.Pipeline_Workflow{{Name: "test", Engine: "nixery", Raw: workflowYaml}}, }, } } func oidcTestSpindle(issuer *oidc.Issuer) *Spindle { return &Spindle{oidc: issuer, l: slog.New(slog.NewTextHandler(io.Discard, nil))} } func decodeJWTClaims(t *testing.T, token string) map[string]any { t.Helper() parts := strings.Split(token, ".") if len(parts) != 3 { t.Fatalf("token has %d parts, want 3", len(parts)) } raw, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { t.Fatal(err) } claims := map[string]any{} if err := json.Unmarshal(raw, &claims); err != nil { t.Fatal(err) } return claims } func TestRunIDTokenMintsFromTheRunRecord(t *testing.T) { s := oidcTestSpindle(oidcTestIssuer(t)) job := oidcTestPushJob(`on: push env: TANGLED_REF: refs/heads/evil TANGLED_REPO_REPO_DID: did:plc:forged TANGLED_ID_TOKEN: smuggled `) token, err := mintFrom(t, s, job, true) if err != nil { t.Fatal(err) } if token == "" { t.Fatal("a trusted in-repo run got no ID token") } claims := decodeJWTClaims(t, token) want := map[string]any{ "iss": oidcTestIssuerURL, "aud": oidcTestAudience, "sub": oidcTestRepoDid + ":refs/heads/main", "repository": oidcTestRepoDid, "ref": "refs/heads/main", "actor": oidcTestActor, "sha": oidcTestSha, } for name, want := range want { if got := claims[name]; got != want { t.Errorf("%s claim = %v, want %v (claims come from the run record, not the workflow definition)", name, got, want) } } for _, name := range []string{"iat", "nbf", "exp", "jti"} { if claims[name] == nil { t.Errorf("%s claim is missing", name) } } if exp, iat := claims["exp"].(float64), claims["iat"].(float64); exp-iat != 900 { t.Errorf("lifetime = %v, want a run-scoped 900s", exp-iat) } } func TestRunIDTokenNeedsATrustedSource(t *testing.T) { s := oidcTestSpindle(oidcTestIssuer(t)) sourceDid := "did:plc:elsewhere" // the shape resolveCheckout produces when a manual trigger names somebody // else's repository: the checkout, and every source-side env var, is the // other repo fork := oidcTestPushJob("") fork.SourceRepo = &tangled.Pipeline_TriggerRepo{RepoDid: &sourceDid, Did: sourceDid, Knot: "knot.test"} if token, err := mintFrom(t, s, fork, false); err != nil || token != "" { t.Fatalf("token = %q err = %v, want no token for an untrusted run", token, err) } // the same record on a trusted run mints, so the assertions above pin the // gate and not a broken fixture if token, err := mintFrom(t, s, fork, true); err != nil || token == "" { t.Fatalf("token = %q err = %v, want a token for the same record when the source is trusted", token, err) } } func TestRunIDTokenClaimsTheRunRepoNotTheSourceRepo(t *testing.T) { s := oidcTestSpindle(oidcTestIssuer(t)) sourceDid := "did:plc:source" job := oidcTestPushJob("") job.Tpl.TriggerMetadata.SourceRepo = &sourceDid job.Tpl.TriggerMetadata.Repo.RepoDid = &sourceDid token, err := mintFrom(t, s, job, true) if err != nil { t.Fatal(err) } if got := decodeJWTClaims(t, token)["repository"]; got != oidcTestRepoDid { t.Fatalf("repository claim = %v, want the run's own repository %q", got, oidcTestRepoDid) } } func TestRunIDTokenSignsTheManualRef(t *testing.T) { s := oidcTestSpindle(oidcTestIssuer(t)) actor, ref := oidcTestActor, "refs/heads/main" repoDid := oidcTestRepoDid // the shape TriggerManual records once it verified the ref's history // contains the sha: the ref is the server's observation, not a claim job := &db.JobRow{ Id: 9, PipelineId: models.PipelineId("3mrkp6iz6os2o"), RepoDid: oidcTestRepoDid, Tpl: tangled.Pipeline{ TriggerMetadata: &tangled.Pipeline_TriggerMetadata{ Kind: "manual", Actor: &actor, Repo: &tangled.Pipeline_TriggerRepo{RepoDid: &repoDid, Did: "did:plc:owner", Knot: "knot.test"}, Manual: &tangled.Pipeline_ManualTriggerData{Sha: oidcTestSha, Ref: &ref}, }, }, } token, err := mintFrom(t, s, job, true) if err != nil { t.Fatal(err) } claims := decodeJWTClaims(t, token) if claims["ref"] != "refs/heads/main" { t.Fatalf("ref claim = %v, want the ref the trigger verified", claims["ref"]) } if claims["sub"] != oidcTestRepoDid+":refs/heads/main" { t.Fatalf("sub claim = %v, want the verified repo:ref subject", claims["sub"]) } if claims["sha"] != oidcTestSha { t.Fatalf("sha claim = %v, want the commit the run targets", claims["sha"]) } } func TestRunIDTokenSkipsRunsWithoutAnIdentity(t *testing.T) { s := oidcTestSpindle(oidcTestIssuer(t)) recordingless := oidcTestPushJob("") recordingless.Tpl.TriggerMetadata = nil if token, err := mintFrom(t, s, recordingless, true); err != nil || token != "" { t.Fatalf("token = %q err = %v, want no token for a run without a record", token, err) } commitless := oidcTestPushJob("") commitless.Tpl.TriggerMetadata.Push.NewSha = "" if token, err := mintFrom(t, s, commitless, true); err != nil || token != "" { t.Fatalf("token = %q err = %v, want no token for a run without a commit", token, err) } unconfigured := oidcTestSpindle(nil) if token, err := mintFrom(t, unconfigured, oidcTestPushJob(""), true); err != nil || token != "" { t.Fatalf("token = %q err = %v, want no token without a configured issuer", token, err) } } // an identity the issuer refuses to sign must leave the run alone: no token, // no error. A push to a legal-but-long refname lands here. func TestIDTokenMinterWithholdsAnUnsignableRun(t *testing.T) { s := oidcTestSpindle(oidcTestIssuer(t)) job := oidcTestPushJob("") job.Tpl.TriggerMetadata.Push.Ref = "refs/heads/" + strings.Repeat("nested/", 80) + "main" minter := s.idTokenMinter(job, true) if minter == nil { t.Fatal("a trusted in-repo run with an unsignable ref should still try to mint") } token, err := minter(10*time.Minute, oidcTestAudience) if err != nil { t.Fatalf("err = %v, want the run to proceed without a token", err) } if token != "" { t.Fatalf("token = %q, want none for a ref the issuer will not sign", token) } } type recordingStep struct{} func (recordingStep) Name() string { return "record" } func (recordingStep) Command() string { return "true" } func (recordingStep) Kind() models.StepKind { return models.StepKindUser } type recordingEngine struct { seen map[string]string } func (e *recordingEngine) InitWorkflow(twf tangled.Pipeline_Workflow, _ tangled.Pipeline) (*models.Workflow, error) { return &models.Workflow{Name: twf.Name, Steps: []models.Step{recordingStep{}}}, nil } func (e *recordingEngine) SetupWorkflow(context.Context, models.WorkflowId, *models.Workflow, models.WorkflowLogger) error { return nil } func (e *recordingEngine) WorkflowTimeout() time.Duration { return time.Minute } func (e *recordingEngine) DestroyWorkflow(context.Context, models.WorkflowId) error { return nil } func (e *recordingEngine) RunStep(_ context.Context, wid models.WorkflowId, w *models.Workflow, _ int, _ []secrets.UnlockedSecret, _ models.WorkflowLogger) error { if e.seen == nil { e.seen = map[string]string{} } e.seen[wid.Name] = w.Audience return nil } func TestRunJobCarriesTheDeclaredAudienceToTheEngine(t *testing.T) { dbDir := t.TempDir() ctx := context.Background() d, err := db.Make(ctx, filepath.Join(dbDir, "spindle.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { d.Close() }) logDir := t.TempDir() cfg := &config.Config{} cfg.Server.LogDir = logDir rec := &recordingEngine{} s := &Spindle{ l: slog.New(slog.NewTextHandler(io.Discard, nil)), metrics: observability.NewMetrics(), db: d, n: ¬ifier.Notifier{}, cfg: cfg, engs: map[string]models.Engine{"record": rec}, } sourceDid := "did:plc:elsewhere" audience := oidcTestAudience job := oidcTestPushJob("") job.SourceRepo = &tangled.Pipeline_TriggerRepo{RepoDid: &sourceDid, Did: sourceDid, Knot: "knot.test"} job.Tpl.Workflows[0].Engine = "record" job.Tpl.Workflows[0].Audience = &audience s.runJob(ctx, job) if got := rec.seen["test"]; got != audience { t.Fatalf("engine saw audience %q, want the workflow record's %q", got, audience) } } // mintFrom runs the minter a workflow would call, and reports a missing minter // as an empty token so the trust-gate assertions read the same either way. func mintFrom(t *testing.T, s *Spindle, job *db.JobRow, trusted bool) (string, error) { t.Helper() minter := s.idTokenMinter(job, trusted) if minter == nil { return "", nil } return minter(10*time.Minute, oidcTestAudience) } func TestRouterServesTheIssuerSurface(t *testing.T) { s := &Spindle{ cfg: &config.Config{Role: config.RoleMill}, l: slog.New(slog.NewTextHandler(io.Discard, nil)), metrics: observability.NewMetrics(), res: idresolver.NewMockResolver(idresolver.MockDirectory{}), oidc: oidcTestIssuer(t), } router := s.Router() fetch := func(path string) *httptest.ResponseRecorder { rec := httptest.NewRecorder() router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) return rec } for _, path := range []string{"/token", "/jwks/", "/.well-known/other"} { if rec := fetch(path); rec.Code != http.StatusNotFound { t.Errorf("GET %s = %d, want 404: the issuer serves nothing but the discovery document and the key set", path, rec.Code) } } var doc struct { Issuer string `json:"issuer"` JWKSURI string `json:"jwks_uri"` Algs []string `json:"id_token_signing_alg_values_supported"` } rec := fetch(oidc.ConfigurationPath) if rec.Code != http.StatusOK { t.Fatalf("GET %s = %d, want 200", oidc.ConfigurationPath, rec.Code) } if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { t.Fatal(err) } if doc.Issuer != oidcTestIssuerURL || len(doc.Algs) != 1 || doc.Algs[0] != "ES256" { t.Errorf("discovery document = %+v, want issuer %q signing with ES256", doc, oidcTestIssuerURL) } rec = fetch(oidc.JWKSPath) if rec.Code != http.StatusOK { t.Fatalf("GET %s = %d, want 200", oidc.JWKSPath, rec.Code) } var set struct { Keys []struct { KeyID string `json:"kid"` Alg string `json:"alg"` } `json:"keys"` } if err := json.Unmarshal(rec.Body.Bytes(), &set); err != nil { t.Fatal(err) } if len(set.Keys) != 1 || set.Keys[0].Alg != "ES256" { t.Fatalf("key set = %+v, want one ES256 key", set.Keys) } }