+242
-2
api/tangled/cbor_gen.go
+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
+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
+1
cmd/gen.go
+1
-1
flake.nix
+1
-1
flake.nix
+17
lexicons/pipeline/pipeline.json
+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
+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
+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
+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
+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