package spindle
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"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/pemkey/pemkeytest"
"tangled.org/core/spindle/secrets"
"tangled.org/core/workflow"
)
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()
path, _ := pemkeytest.WriteP256(t)
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: workflow.PipelineSpec{
TriggerMetadata: &workflow.TriggerMetadata{
Kind: "push",
Actor: &actor,
Push: &workflow.PushTriggerData{Ref: "refs/heads/main", NewSha: oidcTestSha, OldSha: strings.Repeat("0", 40)},
Repo: &workflow.TriggerRepo{
Knot: "knot.test",
Did: "did:plc:owner",
RepoDid: &repoDid,
DefaultBranch: "main",
},
},
Workflows: []*workflow.WorkflowSpec{{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 = &workflow.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: workflow.PipelineSpec{
TriggerMetadata: &workflow.TriggerMetadata{
Kind: "manual",
Actor: &actor,
Repo: &workflow.TriggerRepo{RepoDid: &repoDid, Did: "did:plc:owner", Knot: "knot.test"},
Manual: &workflow.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 workflow.WorkflowSpec, _ workflow.PipelineSpec) (*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 = &workflow.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)
}
}