Skip to content

Commit dce25dd

Browse files
authored
Merge pull request #1214 from fluxcd/ci-jwt
auth: introduce JWT signing with JWK for CI systems
2 parents f50d611 + 55b3741 commit dce25dd

5 files changed

Lines changed: 565 additions & 43 deletions

File tree

auth/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ require (
2626
github.com/fluxcd/pkg/apis/meta v1.27.0
2727
github.com/fluxcd/pkg/cache v0.14.0
2828
github.com/fluxcd/pkg/ssh v0.25.0
29+
github.com/go-jose/go-jose/v4 v4.1.4
2930
github.com/golang-jwt/jwt/v4 v4.5.2
3031
github.com/golang-jwt/jwt/v5 v5.3.1
3132
github.com/google/go-containerregistry v0.21.5
@@ -65,7 +66,6 @@ require (
6566
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
6667
github.com/felixge/httpsnoop v1.0.4 // indirect
6768
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
68-
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
6969
github.com/go-logr/logr v1.4.3 // indirect
7070
github.com/go-logr/stdr v1.2.2 // indirect
7171
github.com/go-openapi/jsonpointer v0.21.0 // indirect

auth/jwt/jwt.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/*
2+
Copyright 2026 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// Package jwt issues self-signed JSON Web Tokens. It parses a private signing
18+
// key from a JSON Web Key (JWK) once and mints compact-serialized tokens on
19+
// demand, stamping the key's id into the token header so verifiers can locate the
20+
// matching public key.
21+
//
22+
// The signing algorithm is derived from the key type, never chosen by the caller
23+
// or read from the JWK's "alg" field, so it can never disagree with the key. Only
24+
// key types that map to a single unambiguous algorithm are supported:
25+
//
26+
// ed25519.PrivateKey -> EdDSA
27+
// *ecdsa.PrivateKey -> ES256 / ES384 / ES512 (by curve: P-256 / P-384 / P-521)
28+
//
29+
// RSA is intentionally unsupported: an RSA key does not determine a single
30+
// algorithm (RS256/384/512, PS256/384/512), so signing one would require the
31+
// library to pick on the caller's behalf.
32+
package jwt
33+
34+
import (
35+
"crypto/ecdsa"
36+
"crypto/ed25519"
37+
"crypto/elliptic"
38+
"crypto/rand"
39+
"encoding/hex"
40+
"encoding/json"
41+
"fmt"
42+
"time"
43+
44+
jose "github.com/go-jose/go-jose/v4"
45+
gojwt "github.com/golang-jwt/jwt/v5"
46+
)
47+
48+
// SigningKey is a private signing key, parsed from a JWK, that mints signed JWTs
49+
// using the algorithm determined by the key type.
50+
type SigningKey struct {
51+
key any
52+
method gojwt.SigningMethod
53+
kid string
54+
}
55+
56+
// ParseJWK parses jwk, a single JSON Web Key, and returns its private signing
57+
// key. The key must be of a type that maps to a single signing algorithm: an
58+
// Ed25519 private key (kty "OKP", crv "Ed25519") or an ECDSA private key (kty
59+
// "EC", crv "P-256", "P-384", or "P-521"), both carrying the private "d"
60+
// component. RSA keys are rejected because their algorithm is ambiguous.
61+
func ParseJWK(jwk string) (*SigningKey, error) {
62+
var k jose.JSONWebKey
63+
if err := json.Unmarshal([]byte(jwk), &k); err != nil {
64+
return nil, fmt.Errorf("failed to parse JWK: %w", err)
65+
}
66+
67+
method, err := signingMethodForKey(k.Key)
68+
if err != nil {
69+
return nil, err
70+
}
71+
72+
return &SigningKey{key: k.Key, method: method, kid: k.KeyID}, nil
73+
}
74+
75+
// signingMethodForKey returns the signing method uniquely determined by the
76+
// private key's type. It errors for key types whose algorithm is not unambiguous
77+
// (RSA) or that are not private signing keys.
78+
func signingMethodForKey(key any) (gojwt.SigningMethod, error) {
79+
switch k := key.(type) {
80+
case ed25519.PrivateKey:
81+
return gojwt.SigningMethodEdDSA, nil
82+
case *ecdsa.PrivateKey:
83+
switch k.Curve {
84+
case elliptic.P256():
85+
return gojwt.SigningMethodES256, nil
86+
case elliptic.P384():
87+
return gojwt.SigningMethodES384, nil
88+
case elliptic.P521():
89+
return gojwt.SigningMethodES512, nil
90+
default:
91+
return nil, fmt.Errorf("unsupported ECDSA curve %q", k.Curve.Params().Name)
92+
}
93+
default:
94+
return nil, fmt.Errorf("unsupported JWK key type %T: "+
95+
"want an Ed25519 or ECDSA (P-256/P-384/P-521) private key", key)
96+
}
97+
}
98+
99+
// clockSkewLeeway backdates the "nbf" claim so a verifier whose clock runs
100+
// slightly behind the issuer's does not reject a freshly minted token as not yet
101+
// valid. It does not extend "exp": the token still expires ttl after issuance.
102+
const clockSkewLeeway = 30 * time.Second
103+
104+
// Issue mints a compact-serialized JWT signed with the key, using the algorithm
105+
// determined by the key type. The signing key's id is set in the "kid" header
106+
// field. The token carries all seven registered claims (RFC 7519): iss, sub, and
107+
// aud as given, iat at the current time, nbf backdated by a small clock-skew
108+
// leeway, exp ttl after issuance, and a random jti.
109+
func (k *SigningKey) Issue(iss, sub, aud string, ttl time.Duration) (string, error) {
110+
jti, err := newJTI()
111+
if err != nil {
112+
return "", err
113+
}
114+
115+
now := time.Now()
116+
claims := gojwt.RegisteredClaims{
117+
Issuer: iss,
118+
Subject: sub,
119+
Audience: gojwt.ClaimStrings{aud},
120+
IssuedAt: gojwt.NewNumericDate(now),
121+
NotBefore: gojwt.NewNumericDate(now.Add(-clockSkewLeeway)),
122+
ExpiresAt: gojwt.NewNumericDate(now.Add(ttl)),
123+
ID: jti,
124+
}
125+
126+
tok := gojwt.NewWithClaims(k.method, claims)
127+
tok.Header["kid"] = k.kid
128+
129+
signed, err := tok.SignedString(k.key)
130+
if err != nil {
131+
return "", fmt.Errorf("failed to sign JWT: %w", err)
132+
}
133+
return signed, nil
134+
}
135+
136+
// newJTI returns a random 128-bit token identifier as a hex string.
137+
func newJTI() (string, error) {
138+
var b [16]byte
139+
if _, err := rand.Read(b[:]); err != nil {
140+
return "", fmt.Errorf("failed to generate JWT ID: %w", err)
141+
}
142+
return hex.EncodeToString(b[:]), nil
143+
}

auth/jwt/jwt_test.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/*
2+
Copyright 2026 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package jwt_test
18+
19+
import (
20+
"crypto"
21+
"crypto/ecdsa"
22+
"crypto/ed25519"
23+
"crypto/elliptic"
24+
"crypto/rand"
25+
"crypto/rsa"
26+
"encoding/json"
27+
"strings"
28+
"testing"
29+
"time"
30+
31+
jose "github.com/go-jose/go-jose/v4"
32+
gojwt "github.com/golang-jwt/jwt/v5"
33+
34+
"github.com/fluxcd/pkg/auth/jwt"
35+
)
36+
37+
func marshalJWK(t *testing.T, key jose.JSONWebKey) string {
38+
t.Helper()
39+
b, err := json.Marshal(key)
40+
if err != nil {
41+
t.Fatalf("failed to marshal JWK: %v", err)
42+
}
43+
return string(b)
44+
}
45+
46+
func TestParseJWK_Errors(t *testing.T) {
47+
_, edPriv, err := ed25519.GenerateKey(rand.Reader)
48+
if err != nil {
49+
t.Fatal(err)
50+
}
51+
rsaPriv, err := rsa.GenerateKey(rand.Reader, 2048)
52+
if err != nil {
53+
t.Fatal(err)
54+
}
55+
56+
tests := []struct {
57+
name string
58+
jwk string
59+
wantErr string
60+
}{
61+
{
62+
name: "not json",
63+
jwk: "{not json",
64+
wantErr: "failed to parse JWK",
65+
},
66+
{
67+
name: "rsa rejected as ambiguous",
68+
jwk: marshalJWK(t, jose.JSONWebKey{Key: rsaPriv, KeyID: "a", Algorithm: "RS256"}),
69+
wantErr: "unsupported JWK key type *rsa.PrivateKey",
70+
},
71+
{
72+
name: "public key only",
73+
jwk: marshalJWK(t, jose.JSONWebKey{Key: edPriv.Public(), KeyID: "a", Algorithm: "EdDSA"}),
74+
wantErr: "unsupported JWK key type ed25519.PublicKey",
75+
},
76+
}
77+
for _, tt := range tests {
78+
t.Run(tt.name, func(t *testing.T) {
79+
_, err := jwt.ParseJWK(tt.jwk)
80+
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
81+
t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err)
82+
}
83+
})
84+
}
85+
}
86+
87+
func TestSigningKey_Issue(t *testing.T) {
88+
ed25519Pub, ed25519Priv, err := ed25519.GenerateKey(rand.Reader)
89+
if err != nil {
90+
t.Fatal(err)
91+
}
92+
es256 := ecKey(t, elliptic.P256())
93+
es384 := ecKey(t, elliptic.P384())
94+
es521 := ecKey(t, elliptic.P521())
95+
96+
tests := []struct {
97+
name string
98+
priv crypto.PrivateKey
99+
pub crypto.PublicKey
100+
wantAlg string
101+
}{
102+
{"ed25519", ed25519Priv, ed25519Pub, "EdDSA"},
103+
{"ecdsa P-256", es256.priv, es256.pub, "ES256"},
104+
{"ecdsa P-384", es384.priv, es384.pub, "ES384"},
105+
{"ecdsa P-521", es521.priv, es521.pub, "ES512"},
106+
}
107+
for _, tt := range tests {
108+
t.Run(tt.name, func(t *testing.T) {
109+
const kid = "my-key"
110+
jwk := marshalJWK(t, jose.JSONWebKey{Key: tt.priv, KeyID: kid})
111+
112+
key, err := jwt.ParseJWK(jwk)
113+
if err != nil {
114+
t.Fatalf("ParseJWK: %v", err)
115+
}
116+
117+
signed, err := key.Issue("https://issuer", "my-subject", "my-audience", 10*time.Second)
118+
if err != nil {
119+
t.Fatalf("Issue: %v", err)
120+
}
121+
122+
claims := gojwt.MapClaims{}
123+
tok, err := gojwt.NewParser().ParseWithClaims(signed, claims, func(*gojwt.Token) (any, error) {
124+
return tt.pub, nil
125+
})
126+
if err != nil {
127+
t.Fatalf("token failed to verify: %v", err)
128+
}
129+
130+
if tok.Method.Alg() != tt.wantAlg {
131+
t.Errorf("alg = %q, want %q", tok.Method.Alg(), tt.wantAlg)
132+
}
133+
if got := tok.Header["kid"]; got != kid {
134+
t.Errorf("kid header = %v, want %q", got, kid)
135+
}
136+
137+
if got, _ := claims.GetIssuer(); got != "https://issuer" {
138+
t.Errorf("iss = %q", got)
139+
}
140+
if got, _ := claims.GetSubject(); got != "my-subject" {
141+
t.Errorf("sub = %q", got)
142+
}
143+
if got, _ := claims.GetAudience(); len(got) != 1 || got[0] != "my-audience" {
144+
t.Errorf("aud = %v", got)
145+
}
146+
iat, _ := claims.GetIssuedAt()
147+
nbf, _ := claims.GetNotBefore()
148+
exp, _ := claims.GetExpirationTime()
149+
if iat == nil || nbf == nil || exp == nil {
150+
t.Fatalf("missing time claims: iat=%v nbf=%v exp=%v", iat, nbf, exp)
151+
}
152+
if want := iat.Add(-30 * time.Second); !nbf.Equal(want) {
153+
t.Errorf("nbf = %s, want %s (iat backdated 30s)", nbf, want)
154+
}
155+
if d := exp.Sub(iat.Time); d != 10*time.Second {
156+
t.Errorf("lifetime = %s, want 10s", d)
157+
}
158+
if jti, ok := claims["jti"].(string); !ok || jti == "" {
159+
t.Errorf("jti = %v, want non-empty string", claims["jti"])
160+
}
161+
})
162+
}
163+
}
164+
165+
func TestSigningKey_Issue_FreshJTIPerCall(t *testing.T) {
166+
_, priv, err := ed25519.GenerateKey(rand.Reader)
167+
if err != nil {
168+
t.Fatal(err)
169+
}
170+
jwk := marshalJWK(t, jose.JSONWebKey{Key: priv, KeyID: "k"})
171+
key, err := jwt.ParseJWK(jwk)
172+
if err != nil {
173+
t.Fatalf("ParseJWK: %v", err)
174+
}
175+
176+
seen := make(map[string]bool)
177+
for range 10 {
178+
signed, err := key.Issue("iss", "sub", "aud", time.Second)
179+
if err != nil {
180+
t.Fatalf("Issue: %v", err)
181+
}
182+
claims := gojwt.MapClaims{}
183+
if _, _, err := gojwt.NewParser().ParseUnverified(signed, claims); err != nil {
184+
t.Fatalf("ParseUnverified: %v", err)
185+
}
186+
jti, _ := claims["jti"].(string)
187+
if seen[jti] {
188+
t.Fatalf("jti %q reused", jti)
189+
}
190+
seen[jti] = true
191+
}
192+
}
193+
194+
type ecPair struct {
195+
priv *ecdsa.PrivateKey
196+
pub *ecdsa.PublicKey
197+
}
198+
199+
// ecKey generates a deterministic-per-call ECDSA key pair on the given curve.
200+
func ecKey(t *testing.T, curve elliptic.Curve) ecPair {
201+
t.Helper()
202+
priv, err := ecdsa.GenerateKey(curve, rand.Reader)
203+
if err != nil {
204+
t.Fatalf("failed to generate ECDSA key: %v", err)
205+
}
206+
return ecPair{priv: priv, pub: &priv.PublicKey}
207+
}

0 commit comments

Comments
 (0)