···
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"
19
+
const JWKSPath = "/.well-known/jwks.json"
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
29
+
// OidcTokenGenerator handles OIDC token generation and key management with rotation
30
+
type OidcTokenGenerator struct {
31
+
currentKeyPair *OidcKeyPair
32
+
nextKeyPair *OidcKeyPair
37
+
// NewOidcTokenGenerator creates a new OIDC token generator with in-memory key management
38
+
func NewOidcTokenGenerator(issuer string) (*OidcTokenGenerator, error) {
40
+
currentKeyPair, err := NewOidcKeyPair()
42
+
return nil, fmt.Errorf("failed to generate initial current key pair: %w", err)
45
+
return &OidcTokenGenerator{
47
+
currentKeyPair: currentKeyPair,
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)
55
+
return nil, fmt.Errorf("failed to generate ECDSA key: %w", err)
58
+
keyID := fmt.Sprintf("spindle-%d", time.Now().Unix())
60
+
// Create JWK from the private key
61
+
jwkKey, err := jwk.FromRaw(privKey)
63
+
return nil, fmt.Errorf("failed to create JWK from private key: %w", err)
67
+
if err := jwkKey.Set(jwk.KeyIDKey, keyID); err != nil {
68
+
return nil, fmt.Errorf("failed to set key ID: %w", err)
72
+
if err := jwkKey.Set(jwk.AlgorithmKey, jwa.ES256); err != nil {
73
+
return nil, fmt.Errorf("failed to set algorithm: %w", err)
77
+
if err := jwkKey.Set(jwk.KeyUsageKey, "sig"); err != nil {
78
+
return nil, fmt.Errorf("failed to set key usage: %w", err)
81
+
return &OidcKeyPair{
82
+
privateKey: privKey,
83
+
publicKey: &privKey.PublicKey,
89
+
// LoadOidcKeyPair loads an existing key pair from JWK JSON
90
+
func LoadOidcKeyPair(jwkJSON []byte) (*OidcKeyPair, error) {
91
+
jwkKey, err := jwk.ParseKey(jwkJSON)
93
+
return nil, fmt.Errorf("failed to parse JWK: %w", err)
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)
101
+
keyID, ok := jwkKey.Get(jwk.KeyIDKey)
103
+
return nil, fmt.Errorf("JWK missing key ID")
106
+
keyIDStr, ok := keyID.(string)
108
+
return nil, fmt.Errorf("JWK key ID is not a string")
111
+
return &OidcKeyPair{
112
+
privateKey: privKey,
113
+
publicKey: &privKey.PublicKey,
119
+
// GetKeyID returns the key ID
120
+
func (k *OidcKeyPair) GetKeyID() string {
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()
129
+
return fmt.Errorf("failed to generate new next key pair: %w", err)
132
+
// Perform rotation: next becomes current, new key becomes next
133
+
g.currentKeyPair = g.nextKeyPair
134
+
g.nextKeyPair = newNextKeyPair
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()
142
+
return fmt.Errorf("failed to generate next key pair for first setup: %w", err)
149
+
func (g *OidcTokenGenerator) GetCurrentKeyID() string {
150
+
if g.currentKeyPair == nil {
153
+
return g.currentKeyPair.GetKeyID()
156
+
// GetNextKeyID returns the next key's ID
157
+
func (g *OidcTokenGenerator) GetNextKeyID() string {
158
+
if g.nextKeyPair == nil {
161
+
return g.nextKeyPair.GetKeyID()
164
+
// HasKeys returns true if the generator has at least a current key
165
+
func (g *OidcTokenGenerator) HasKeys() bool {
166
+
return g.currentKeyPair != nil
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"`
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) {
188
+
exp := now.Add(5 * time.Minute)
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
196
+
pipelineUri := pipelineId.AtUri()
199
+
claims := OidcClaims{
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()),
211
+
// Create JWT token
215
+
if err := token.Set(jwt.IssuerKey, claims.Issuer); err != nil {
216
+
return "", fmt.Errorf("failed to set issuer: %w", err)
218
+
if err := token.Set(jwt.SubjectKey, claims.Subject); err != nil {
219
+
return "", fmt.Errorf("failed to set subject: %w", err)
221
+
if err := token.Set(jwt.AudienceKey, claims.Audience); err != nil {
222
+
return "", fmt.Errorf("failed to set audience: %w", err)
224
+
if err := token.Set(jwt.ExpirationKey, claims.ExpiresAt); err != nil {
225
+
return "", fmt.Errorf("failed to set expiration: %w", err)
227
+
if err := token.Set(jwt.NotBeforeKey, claims.NotBefore); err != nil {
228
+
return "", fmt.Errorf("failed to set not before: %w", err)
230
+
if err := token.Set(jwt.IssuedAtKey, claims.IssuedAt); err != nil {
231
+
return "", fmt.Errorf("failed to set issued at: %w", err)
233
+
if err := token.Set(jwt.JwtIDKey, claims.JWTID); err != nil {
234
+
return "", fmt.Errorf("failed to set JWT ID: %w", err)
237
+
// Sign the token with the current key
238
+
if g.currentKeyPair == nil {
239
+
return "", fmt.Errorf("no current key pair available for signing")
241
+
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, g.currentKeyPair.jwkKey))
243
+
return "", fmt.Errorf("failed to sign token: %w", err)
246
+
return string(signedToken), nil
249
+
// JWKSHandler serves the JWKS endpoint as an HTTP handler
250
+
func (g *OidcTokenGenerator) JWKSHandler(w http.ResponseWriter, r *http.Request) {
253
+
// Add current key if available
254
+
if g.currentKeyPair != nil {
255
+
pubJWK, err := jwk.PublicKeyOf(g.currentKeyPair.jwkKey)
257
+
http.Error(w, fmt.Sprintf("failed to extract current public key from JWK: %v", err), http.StatusInternalServerError)
260
+
keys = append(keys, pubJWK)
263
+
// Add next key if available
264
+
if g.nextKeyPair != nil {
265
+
pubJWK, err := jwk.PublicKeyOf(g.nextKeyPair.jwkKey)
267
+
http.Error(w, fmt.Sprintf("failed to extract next public key from JWK: %v", err), http.StatusInternalServerError)
270
+
keys = append(keys, pubJWK)
273
+
if len(keys) == 0 {
274
+
http.Error(w, "no keys available for JWKS", http.StatusInternalServerError)
278
+
jwks := map[string]interface{}{
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)
288
+
// DiscoveryHandler serves the OIDC discovery endpoint for JWKS
289
+
func (g *OidcTokenGenerator) DiscoveryHandler(w http.ResponseWriter, r *http.Request) {
290
+
claimsSupported := []string{
300
+
responseTypesSupported := []string{
304
+
subjectTypesSupported := []string{
308
+
idTokenSigningAlgValuesSupported := []string{
309
+
jwa.RS256.String(),
312
+
scopesSupported := []string{
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,
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)