forked from tangled.org/core
this repo has no description

Initial implementation sketch

tom.sherman.is fb64beba 7e4a93c0

verified
Changed files
+640 -12
api
cmd
lexicons
pipeline
spindle
engine
models
oidc
+242 -2
api/tangled/cbor_gen.go
··· 3923 3923 } 3924 3924 3925 3925 cw := cbg.NewCborWriter(w) 3926 - fieldCount := 3 3926 + fieldCount := 4 3927 3927 3928 3928 if t.Environment == nil { 3929 + fieldCount-- 3930 + } 3931 + 3932 + if t.Oidcs_tokens == nil { 3929 3933 fieldCount-- 3930 3934 } 3931 3935 ··· 4007 4011 4008 4012 } 4009 4013 } 4014 + 4015 + // t.Oidcs_tokens ([]*tangled.Pipeline_Step_Oidcs_tokens_Elem) (slice) 4016 + if t.Oidcs_tokens != nil { 4017 + 4018 + if len("oidcs_tokens") > 1000000 { 4019 + return xerrors.Errorf("Value in field \"oidcs_tokens\" was too long") 4020 + } 4021 + 4022 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("oidcs_tokens"))); err != nil { 4023 + return err 4024 + } 4025 + if _, err := cw.WriteString(string("oidcs_tokens")); err != nil { 4026 + return err 4027 + } 4028 + 4029 + if len(t.Oidcs_tokens) > 8192 { 4030 + return xerrors.Errorf("Slice value in field t.Oidcs_tokens was too long") 4031 + } 4032 + 4033 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Oidcs_tokens))); err != nil { 4034 + return err 4035 + } 4036 + for _, v := range t.Oidcs_tokens { 4037 + if err := v.MarshalCBOR(cw); err != nil { 4038 + return err 4039 + } 4040 + 4041 + } 4042 + } 4010 4043 return nil 4011 4044 } 4012 4045 ··· 4035 4068 4036 4069 n := extra 4037 4070 4038 - nameBuf := make([]byte, 11) 4071 + nameBuf := make([]byte, 12) 4039 4072 for i := uint64(0); i < n; i++ { 4040 4073 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4041 4074 if err != nil { ··· 4121 4154 } 4122 4155 4123 4156 } 4157 + } 4158 + // t.Oidcs_tokens ([]*tangled.Pipeline_Step_Oidcs_tokens_Elem) (slice) 4159 + case "oidcs_tokens": 4160 + 4161 + maj, extra, err = cr.ReadHeader() 4162 + if err != nil { 4163 + return err 4164 + } 4165 + 4166 + if extra > 8192 { 4167 + return fmt.Errorf("t.Oidcs_tokens: array too large (%d)", extra) 4168 + } 4169 + 4170 + if maj != cbg.MajArray { 4171 + return fmt.Errorf("expected cbor array") 4172 + } 4173 + 4174 + if extra > 0 { 4175 + t.Oidcs_tokens = make([]*Pipeline_Step_Oidcs_tokens_Elem, extra) 4176 + } 4177 + 4178 + for i := 0; i < int(extra); i++ { 4179 + { 4180 + var maj byte 4181 + var extra uint64 4182 + var err error 4183 + _ = maj 4184 + _ = extra 4185 + _ = err 4186 + 4187 + { 4188 + 4189 + b, err := cr.ReadByte() 4190 + if err != nil { 4191 + return err 4192 + } 4193 + if b != cbg.CborNull[0] { 4194 + if err := cr.UnreadByte(); err != nil { 4195 + return err 4196 + } 4197 + t.Oidcs_tokens[i] = new(Pipeline_Step_Oidcs_tokens_Elem) 4198 + if err := t.Oidcs_tokens[i].UnmarshalCBOR(cr); err != nil { 4199 + return xerrors.Errorf("unmarshaling t.Oidcs_tokens[i] pointer: %w", err) 4200 + } 4201 + } 4202 + 4203 + } 4204 + 4205 + } 4206 + } 4207 + 4208 + default: 4209 + // Field doesn't exist on this type, so ignore it 4210 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 4211 + return err 4212 + } 4213 + } 4214 + } 4215 + 4216 + return nil 4217 + } 4218 + func (t *Pipeline_Step_Oidcs_tokens_Elem) MarshalCBOR(w io.Writer) error { 4219 + if t == nil { 4220 + _, err := w.Write(cbg.CborNull) 4221 + return err 4222 + } 4223 + 4224 + cw := cbg.NewCborWriter(w) 4225 + fieldCount := 2 4226 + 4227 + if t.Aud == nil { 4228 + fieldCount-- 4229 + } 4230 + 4231 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 4232 + return err 4233 + } 4234 + 4235 + // t.Aud (string) (string) 4236 + if t.Aud != nil { 4237 + 4238 + if len("aud") > 1000000 { 4239 + return xerrors.Errorf("Value in field \"aud\" was too long") 4240 + } 4241 + 4242 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("aud"))); err != nil { 4243 + return err 4244 + } 4245 + if _, err := cw.WriteString(string("aud")); err != nil { 4246 + return err 4247 + } 4248 + 4249 + if t.Aud == nil { 4250 + if _, err := cw.Write(cbg.CborNull); err != nil { 4251 + return err 4252 + } 4253 + } else { 4254 + if len(*t.Aud) > 1000000 { 4255 + return xerrors.Errorf("Value in field t.Aud was too long") 4256 + } 4257 + 4258 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Aud))); err != nil { 4259 + return err 4260 + } 4261 + if _, err := cw.WriteString(string(*t.Aud)); err != nil { 4262 + return err 4263 + } 4264 + } 4265 + } 4266 + 4267 + // t.Name (string) (string) 4268 + if len("name") > 1000000 { 4269 + return xerrors.Errorf("Value in field \"name\" was too long") 4270 + } 4271 + 4272 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil { 4273 + return err 4274 + } 4275 + if _, err := cw.WriteString(string("name")); err != nil { 4276 + return err 4277 + } 4278 + 4279 + if len(t.Name) > 1000000 { 4280 + return xerrors.Errorf("Value in field t.Name was too long") 4281 + } 4282 + 4283 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil { 4284 + return err 4285 + } 4286 + if _, err := cw.WriteString(string(t.Name)); err != nil { 4287 + return err 4288 + } 4289 + return nil 4290 + } 4291 + 4292 + func (t *Pipeline_Step_Oidcs_tokens_Elem) UnmarshalCBOR(r io.Reader) (err error) { 4293 + *t = Pipeline_Step_Oidcs_tokens_Elem{} 4294 + 4295 + cr := cbg.NewCborReader(r) 4296 + 4297 + maj, extra, err := cr.ReadHeader() 4298 + if err != nil { 4299 + return err 4300 + } 4301 + defer func() { 4302 + if err == io.EOF { 4303 + err = io.ErrUnexpectedEOF 4304 + } 4305 + }() 4306 + 4307 + if maj != cbg.MajMap { 4308 + return fmt.Errorf("cbor input should be of type map") 4309 + } 4310 + 4311 + if extra > cbg.MaxLength { 4312 + return fmt.Errorf("Pipeline_Step_Oidcs_tokens_Elem: map struct too large (%d)", extra) 4313 + } 4314 + 4315 + n := extra 4316 + 4317 + nameBuf := make([]byte, 4) 4318 + for i := uint64(0); i < n; i++ { 4319 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4320 + if err != nil { 4321 + return err 4322 + } 4323 + 4324 + if !ok { 4325 + // Field doesn't exist on this type, so ignore it 4326 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 4327 + return err 4328 + } 4329 + continue 4330 + } 4331 + 4332 + switch string(nameBuf[:nameLen]) { 4333 + // t.Aud (string) (string) 4334 + case "aud": 4335 + 4336 + { 4337 + b, err := cr.ReadByte() 4338 + if err != nil { 4339 + return err 4340 + } 4341 + if b != cbg.CborNull[0] { 4342 + if err := cr.UnreadByte(); err != nil { 4343 + return err 4344 + } 4345 + 4346 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4347 + if err != nil { 4348 + return err 4349 + } 4350 + 4351 + t.Aud = (*string)(&sval) 4352 + } 4353 + } 4354 + // t.Name (string) (string) 4355 + case "name": 4356 + 4357 + { 4358 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4359 + if err != nil { 4360 + return err 4361 + } 4362 + 4363 + t.Name = string(sval) 4124 4364 } 4125 4365 4126 4366 default:
+9 -3
api/tangled/tangledpipeline.go
··· 63 63 64 64 // Pipeline_Step is a "step" in the sh.tangled.pipeline schema. 65 65 type Pipeline_Step struct { 66 - Command string `json:"command" cborgen:"command"` 67 - Environment []*Pipeline_Pair `json:"environment,omitempty" cborgen:"environment,omitempty"` 68 - Name string `json:"name" cborgen:"name"` 66 + Command string `json:"command" cborgen:"command"` 67 + Environment []*Pipeline_Pair `json:"environment,omitempty" cborgen:"environment,omitempty"` 68 + Name string `json:"name" cborgen:"name"` 69 + Oidcs_tokens []*Pipeline_Step_Oidcs_tokens_Elem `json:"oidcs_tokens,omitempty" cborgen:"oidcs_tokens,omitempty"` 70 + } 71 + 72 + type Pipeline_Step_Oidcs_tokens_Elem struct { 73 + Aud *string `json:"aud,omitempty" cborgen:"aud,omitempty"` 74 + Name string `json:"name" cborgen:"name"` 69 75 } 70 76 71 77 // Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema.
+1
cmd/gen.go
··· 34 34 tangled.Pipeline_PushTriggerData{}, 35 35 tangled.PipelineStatus{}, 36 36 tangled.Pipeline_Step{}, 37 + tangled.Pipeline_Step_Oidcs_tokens_Elem{}, 37 38 tangled.Pipeline_TriggerMetadata{}, 38 39 tangled.Pipeline_TriggerRepo{}, 39 40 tangled.Pipeline_Workflow{},
+1 -1
flake.nix
··· 116 116 stdenv = pkgs.pkgsStatic.stdenv; 117 117 }; 118 118 in { 119 - default = staticShell { 119 + default = pkgs.mkShell { 120 120 nativeBuildInputs = [ 121 121 pkgs.go 122 122 pkgs.air
+17
lexicons/pipeline/pipeline.json
··· 241 241 "type": "ref", 242 242 "ref": "#pair" 243 243 } 244 + }, 245 + "oidcs_tokens": { 246 + "type": "array", 247 + "items": { 248 + "type": "object", 249 + "required": [ 250 + "name" 251 + ], 252 + "properties": { 253 + "name": { 254 + "type": "string" 255 + }, 256 + "aud": { 257 + "type": "string" 258 + } 259 + } 260 + } 244 261 } 245 262 } 246 263 },
+15 -3
spindle/engine/engine.go
··· 25 25 "tangled.sh/tangled.sh/core/spindle/config" 26 26 "tangled.sh/tangled.sh/core/spindle/db" 27 27 "tangled.sh/tangled.sh/core/spindle/models" 28 + "tangled.sh/tangled.sh/core/spindle/oidc" 28 29 "tangled.sh/tangled.sh/core/spindle/secrets" 29 30 ) 30 31 ··· 41 42 n *notifier.Notifier 42 43 cfg *config.Config 43 44 vault secrets.Manager 45 + oidc oidc.OidcTokenGenerator 44 46 45 47 cleanupMu sync.Mutex 46 48 cleanup map[string][]cleanupFunc 47 49 } 48 50 49 - func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) { 51 + func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager, oidc *oidc.OidcTokenGenerator) (*Engine, error) { 50 52 dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 51 53 if err != nil { 52 54 return nil, err ··· 61 63 n: n, 62 64 cfg: cfg, 63 65 vault: vault, 66 + oidc: *oidc, 64 67 } 65 68 66 69 e.cleanup = make(map[string][]cleanupFunc) ··· 124 127 ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 125 128 defer cancel() 126 129 127 - err = e.StartSteps(ctx, wid, w, allSecrets) 130 + err = e.StartSteps(ctx, wid, w, allSecrets, pipeline, pipelineId) 128 131 if err != nil { 129 132 if errors.Is(err, ErrTimedOut) { 130 133 dbErr := e.db.StatusTimeout(wid, e.n) ··· 202 205 // ONLY marks pipeline as failed if container's exit code is non-zero. 203 206 // All other errors are bubbled up. 204 207 // Fixed version of the step execution logic 205 - func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret) error { 208 + func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret, pipeline *models.Pipeline, pipelineId models.PipelineId) error { 206 209 workflowEnvs := ConstructEnvs(w.Environment) 207 210 for _, s := range secrets { 208 211 workflowEnvs.AddEnv(s.Key, s.Value) ··· 221 224 } 222 225 envs.AddEnv("HOME", workspaceDir) 223 226 e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice()) 227 + 228 + for _, t := range step.OidcTokens { 229 + token, err := e.oidc.CreateToken(t, pipelineId, pipeline.RepoOwner, pipeline.RepoName) 230 + if err != nil { 231 + e.l.Error("failed to get OIDC token", "error", err, "token", t.Name) 232 + return fmt.Errorf("getting OIDC token: %w", err) 233 + } 234 + envs.AddEnv(t.Name, token) 235 + } 224 236 225 237 hostConfig := hostConfig(wid) 226 238 resp, err := e.docker.ContainerCreate(ctx, &container.Config{
+14
spindle/models/pipeline.go
··· 18 18 Name string 19 19 Environment map[string]string 20 20 Kind StepKind 21 + OidcTokens []OidcToken 21 22 } 22 23 23 24 type StepKind int ··· 28 29 // steps defined by the user in the original pipeline 29 30 StepKindUser 30 31 ) 32 + 33 + type OidcToken struct { 34 + Name string 35 + Aud *string 36 + } 31 37 32 38 type Workflow struct { 33 39 Steps []Step ··· 60 66 sstep.Name = tstep.Name 61 67 sstep.Kind = StepKindUser 62 68 swf.Steps = append(swf.Steps, sstep) 69 + 70 + sstep.OidcTokens = make([]OidcToken, 0, len(tstep.Oidcs_tokens)) 71 + for _, ttoken := range tstep.Oidcs_tokens { 72 + sstep.OidcTokens = append(sstep.OidcTokens, OidcToken{ 73 + Name: ttoken.Name, 74 + Aud: ttoken.Aud, 75 + }) 76 + } 63 77 } 64 78 swf.Name = twf.Name 65 79 swf.Environment = workflowEnvToMap(twf.Environment)
+329
spindle/oidc/oidc.go
··· 1 + package oidc 2 + 3 + import ( 4 + "crypto/ecdsa" 5 + "crypto/elliptic" 6 + "crypto/rand" 7 + "encoding/json" 8 + "fmt" 9 + "log/slog" 10 + "net/http" 11 + "time" 12 + 13 + "github.com/lestrrat-go/jwx/v2/jwa" 14 + "github.com/lestrrat-go/jwx/v2/jwk" 15 + "github.com/lestrrat-go/jwx/v2/jwt" 16 + "tangled.sh/tangled.sh/core/spindle/models" 17 + ) 18 + 19 + const JWKSPath = "/.well-known/jwks.json" 20 + 21 + // OidcKeyPair represents an OIDC key pair with both private and public keys 22 + type OidcKeyPair struct { 23 + privateKey *ecdsa.PrivateKey 24 + publicKey *ecdsa.PublicKey 25 + keyID string 26 + jwkKey jwk.Key 27 + } 28 + 29 + // OidcTokenGenerator handles OIDC token generation and key management with rotation 30 + type OidcTokenGenerator struct { 31 + currentKeyPair *OidcKeyPair 32 + nextKeyPair *OidcKeyPair 33 + l *slog.Logger 34 + issuer string 35 + } 36 + 37 + // NewOidcTokenGenerator creates a new OIDC token generator with in-memory key management 38 + func NewOidcTokenGenerator(issuer string) (*OidcTokenGenerator, error) { 39 + // Create new keys 40 + currentKeyPair, err := NewOidcKeyPair() 41 + if err != nil { 42 + return nil, fmt.Errorf("failed to generate initial current key pair: %w", err) 43 + } 44 + 45 + return &OidcTokenGenerator{ 46 + issuer: issuer, 47 + currentKeyPair: currentKeyPair, 48 + }, nil 49 + } 50 + 51 + // NewOidcKeyPair generates a new ECDSA key pair for OIDC token signing 52 + func NewOidcKeyPair() (*OidcKeyPair, error) { 53 + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 54 + if err != nil { 55 + return nil, fmt.Errorf("failed to generate ECDSA key: %w", err) 56 + } 57 + 58 + keyID := fmt.Sprintf("spindle-%d", time.Now().Unix()) 59 + 60 + // Create JWK from the private key 61 + jwkKey, err := jwk.FromRaw(privKey) 62 + if err != nil { 63 + return nil, fmt.Errorf("failed to create JWK from private key: %w", err) 64 + } 65 + 66 + // Set the key ID 67 + if err := jwkKey.Set(jwk.KeyIDKey, keyID); err != nil { 68 + return nil, fmt.Errorf("failed to set key ID: %w", err) 69 + } 70 + 71 + // Set algorithm 72 + if err := jwkKey.Set(jwk.AlgorithmKey, jwa.ES256); err != nil { 73 + return nil, fmt.Errorf("failed to set algorithm: %w", err) 74 + } 75 + 76 + // Set usage 77 + if err := jwkKey.Set(jwk.KeyUsageKey, "sig"); err != nil { 78 + return nil, fmt.Errorf("failed to set key usage: %w", err) 79 + } 80 + 81 + return &OidcKeyPair{ 82 + privateKey: privKey, 83 + publicKey: &privKey.PublicKey, 84 + keyID: keyID, 85 + jwkKey: jwkKey, 86 + }, nil 87 + } 88 + 89 + // LoadOidcKeyPair loads an existing key pair from JWK JSON 90 + func LoadOidcKeyPair(jwkJSON []byte) (*OidcKeyPair, error) { 91 + jwkKey, err := jwk.ParseKey(jwkJSON) 92 + if err != nil { 93 + return nil, fmt.Errorf("failed to parse JWK: %w", err) 94 + } 95 + 96 + var privKey *ecdsa.PrivateKey 97 + if err := jwkKey.Raw(&privKey); err != nil { 98 + return nil, fmt.Errorf("failed to extract private key: %w", err) 99 + } 100 + 101 + keyID, ok := jwkKey.Get(jwk.KeyIDKey) 102 + if !ok { 103 + return nil, fmt.Errorf("JWK missing key ID") 104 + } 105 + 106 + keyIDStr, ok := keyID.(string) 107 + if !ok { 108 + return nil, fmt.Errorf("JWK key ID is not a string") 109 + } 110 + 111 + return &OidcKeyPair{ 112 + privateKey: privKey, 113 + publicKey: &privKey.PublicKey, 114 + keyID: keyIDStr, 115 + jwkKey: jwkKey, 116 + }, nil 117 + } 118 + 119 + // GetKeyID returns the key ID 120 + func (k *OidcKeyPair) GetKeyID() string { 121 + return k.keyID 122 + } 123 + 124 + // RotateKeys performs key rotation: generates new next key, moves next to current 125 + func (g *OidcTokenGenerator) RotateKeys() error { 126 + // Generate a new key pair for the next key 127 + newNextKeyPair, err := NewOidcKeyPair() 128 + if err != nil { 129 + return fmt.Errorf("failed to generate new next key pair: %w", err) 130 + } 131 + 132 + // Perform rotation: next becomes current, new key becomes next 133 + g.currentKeyPair = g.nextKeyPair 134 + g.nextKeyPair = newNextKeyPair 135 + 136 + // If we don't have a current key (first time setup), use the new key 137 + if g.currentKeyPair == nil { 138 + g.currentKeyPair = newNextKeyPair 139 + // Generate another new key for next 140 + g.nextKeyPair, err = NewOidcKeyPair() 141 + if err != nil { 142 + return fmt.Errorf("failed to generate next key pair for first setup: %w", err) 143 + } 144 + } 145 + 146 + return nil 147 + } 148 + 149 + func (g *OidcTokenGenerator) GetCurrentKeyID() string { 150 + if g.currentKeyPair == nil { 151 + return "" 152 + } 153 + return g.currentKeyPair.GetKeyID() 154 + } 155 + 156 + // GetNextKeyID returns the next key's ID 157 + func (g *OidcTokenGenerator) GetNextKeyID() string { 158 + if g.nextKeyPair == nil { 159 + return "" 160 + } 161 + return g.nextKeyPair.GetKeyID() 162 + } 163 + 164 + // HasKeys returns true if the generator has at least a current key 165 + func (g *OidcTokenGenerator) HasKeys() bool { 166 + return g.currentKeyPair != nil 167 + } 168 + 169 + // OidcClaims represents the claims in an OIDC token 170 + type OidcClaims struct { 171 + // Standard JWT claims 172 + Issuer string `json:"iss"` 173 + Subject string `json:"sub"` 174 + Audience string `json:"aud"` 175 + ExpiresAt int64 `json:"exp"` 176 + NotBefore int64 `json:"nbf"` 177 + IssuedAt int64 `json:"iat"` 178 + JWTID string `json:"jti"` 179 + } 180 + 181 + // CreateToken creates a signed JWT token for the given OidcToken and pipeline context 182 + func (g *OidcTokenGenerator) CreateToken( 183 + oidcToken models.OidcToken, 184 + pipelineId models.PipelineId, 185 + repoOwner, repoName string, 186 + ) (string, error) { 187 + now := time.Now() 188 + exp := now.Add(5 * time.Minute) 189 + 190 + // Determine audience - use the provided audience or default to issuer 191 + audience := fmt.Sprintf(g.issuer) 192 + if oidcToken.Aud != nil && *oidcToken.Aud != "" { 193 + audience = *oidcToken.Aud 194 + } 195 + 196 + pipelineUri := pipelineId.AtUri() 197 + 198 + // Create claims 199 + claims := OidcClaims{ 200 + Issuer: g.issuer, 201 + // Hardcode the did as did:web of the issuer. At some point knots will have their own DIDs which will be used here 202 + Subject: pipelineUri.String(), 203 + Audience: audience, 204 + ExpiresAt: exp.Unix(), 205 + NotBefore: now.Unix(), 206 + IssuedAt: now.Unix(), 207 + // Repo owner, name, and id should be global unique but we add timestamp to ensure uniqueness 208 + JWTID: fmt.Sprintf("%s/%s-%s-%d", repoOwner, repoName, pipelineUri.RecordKey(), now.Unix()), 209 + } 210 + 211 + // Create JWT token 212 + token := jwt.New() 213 + 214 + // Set all claims 215 + if err := token.Set(jwt.IssuerKey, claims.Issuer); err != nil { 216 + return "", fmt.Errorf("failed to set issuer: %w", err) 217 + } 218 + if err := token.Set(jwt.SubjectKey, claims.Subject); err != nil { 219 + return "", fmt.Errorf("failed to set subject: %w", err) 220 + } 221 + if err := token.Set(jwt.AudienceKey, claims.Audience); err != nil { 222 + return "", fmt.Errorf("failed to set audience: %w", err) 223 + } 224 + if err := token.Set(jwt.ExpirationKey, claims.ExpiresAt); err != nil { 225 + return "", fmt.Errorf("failed to set expiration: %w", err) 226 + } 227 + if err := token.Set(jwt.NotBeforeKey, claims.NotBefore); err != nil { 228 + return "", fmt.Errorf("failed to set not before: %w", err) 229 + } 230 + if err := token.Set(jwt.IssuedAtKey, claims.IssuedAt); err != nil { 231 + return "", fmt.Errorf("failed to set issued at: %w", err) 232 + } 233 + if err := token.Set(jwt.JwtIDKey, claims.JWTID); err != nil { 234 + return "", fmt.Errorf("failed to set JWT ID: %w", err) 235 + } 236 + 237 + // Sign the token with the current key 238 + if g.currentKeyPair == nil { 239 + return "", fmt.Errorf("no current key pair available for signing") 240 + } 241 + signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, g.currentKeyPair.jwkKey)) 242 + if err != nil { 243 + return "", fmt.Errorf("failed to sign token: %w", err) 244 + } 245 + 246 + return string(signedToken), nil 247 + } 248 + 249 + // JWKSHandler serves the JWKS endpoint as an HTTP handler 250 + func (g *OidcTokenGenerator) JWKSHandler(w http.ResponseWriter, r *http.Request) { 251 + var keys []jwk.Key 252 + 253 + // Add current key if available 254 + if g.currentKeyPair != nil { 255 + pubJWK, err := jwk.PublicKeyOf(g.currentKeyPair.jwkKey) 256 + if err != nil { 257 + http.Error(w, fmt.Sprintf("failed to extract current public key from JWK: %v", err), http.StatusInternalServerError) 258 + return 259 + } 260 + keys = append(keys, pubJWK) 261 + } 262 + 263 + // Add next key if available 264 + if g.nextKeyPair != nil { 265 + pubJWK, err := jwk.PublicKeyOf(g.nextKeyPair.jwkKey) 266 + if err != nil { 267 + http.Error(w, fmt.Sprintf("failed to extract next public key from JWK: %v", err), http.StatusInternalServerError) 268 + return 269 + } 270 + keys = append(keys, pubJWK) 271 + } 272 + 273 + if len(keys) == 0 { 274 + http.Error(w, "no keys available for JWKS", http.StatusInternalServerError) 275 + return 276 + } 277 + 278 + jwks := map[string]interface{}{ 279 + "keys": keys, 280 + } 281 + 282 + w.Header().Set("Content-Type", "application/json") 283 + if err := json.NewEncoder(w).Encode(jwks); err != nil { 284 + http.Error(w, fmt.Sprintf("failed to encode JWKS: %v", err), http.StatusInternalServerError) 285 + } 286 + } 287 + 288 + // DiscoveryHandler serves the OIDC discovery endpoint for JWKS 289 + func (g *OidcTokenGenerator) DiscoveryHandler(w http.ResponseWriter, r *http.Request) { 290 + claimsSupported := []string{ 291 + "iss", 292 + "sub", 293 + "aud", 294 + "exp", 295 + "nbf", 296 + "iat", 297 + "jti", 298 + } 299 + 300 + responseTypesSupported := []string{ 301 + "id_token", 302 + } 303 + 304 + subjectTypesSupported := []string{ 305 + "public", 306 + } 307 + 308 + idTokenSigningAlgValuesSupported := []string{ 309 + jwa.RS256.String(), 310 + } 311 + 312 + scopesSupported := []string{ 313 + "openid", 314 + } 315 + 316 + discovery := map[string]interface{}{ 317 + "issuer": g.issuer, 318 + "jwks_uri": fmt.Sprintf("%s%s", g.issuer, JWKSPath), 319 + "claims_supported": claimsSupported, 320 + "response_types_supported": responseTypesSupported, 321 + "subject_types_supported": subjectTypesSupported, 322 + "id_token_signing_alg_values_supported": idTokenSigningAlgValuesSupported, 323 + "scopes_supported": scopesSupported, 324 + } 325 + w.Header().Set("Content-Type", "application/json") 326 + if err := json.NewEncoder(w).Encode(discovery); err != nil { 327 + http.Error(w, fmt.Sprintf("failed to encode discovery document: %v", err), http.StatusInternalServerError) 328 + } 329 + }
+12 -3
spindle/server.go
··· 21 21 "tangled.sh/tangled.sh/core/spindle/db" 22 22 "tangled.sh/tangled.sh/core/spindle/engine" 23 23 "tangled.sh/tangled.sh/core/spindle/models" 24 + "tangled.sh/tangled.sh/core/spindle/oidc" 24 25 "tangled.sh/tangled.sh/core/spindle/queue" 25 26 "tangled.sh/tangled.sh/core/spindle/secrets" 26 27 "tangled.sh/tangled.sh/core/spindle/xrpc" ··· 93 94 return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 94 95 } 95 96 96 - eng, err := engine.New(ctx, cfg, d, &n, vault) 97 + oidc, err := oidc.NewOidcTokenGenerator(cfg.Server.Hostname) 98 + if err != nil { 99 + return fmt.Errorf("failed to create OIDC token generator: %w", err) 100 + } 101 + 102 + eng, err := engine.New(ctx, cfg, d, &n, vault, oidc) 97 103 if err != nil { 98 104 return err 99 105 } ··· 188 194 }() 189 195 190 196 logger.Info("starting spindle server", "address", cfg.Server.ListenAddr) 191 - logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router())) 197 + logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router(oidc))) 192 198 193 199 return nil 194 200 } 195 201 196 - func (s *Spindle) Router() http.Handler { 202 + func (s *Spindle) Router(oidcg *oidc.OidcTokenGenerator) http.Handler { 197 203 mux := chi.NewRouter() 198 204 199 205 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { ··· 204 210 w.Write([]byte(s.cfg.Server.Owner)) 205 211 }) 206 212 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 213 + mux.HandleFunc(oidc.JWKSPath, oidcg.JWKSHandler) 214 + mux.HandleFunc("/.well-known/oidc-configuration", oidcg.DiscoveryHandler) 215 + // TODO: Do we need webfinger issuer discovery? 207 216 208 217 mux.Mount("/xrpc", s.XrpcRouter()) 209 218 return mux