Skip to content

Commit e7603b1

Browse files
committed
Add support for static JWKS
Signed-off-by: Matheus Pimenta <[email protected]>
1 parent 2b8abcc commit e7603b1

File tree

7 files changed

+240
-23
lines changed

7 files changed

+240
-23
lines changed

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,32 @@ permissions:
5555
contents: read
5656
```
5757
58+
It's also possible to set a static JWKS JSON document for verifying the token
59+
signature. This is useful for cases where the OIDC discovery endpoint is not
60+
reachable from the Internet, e.g. in air-gapped environments. Example:
61+
62+
```yaml
63+
issuer: https://kubernetes.default.svc.cluster.local
64+
audience: https://kubernetes.default.svc.cluster.local
65+
subject: system:serviceaccount:my-app:my-app
66+
jwks: |
67+
{
68+
"keys": [
69+
{
70+
"use": "sig",
71+
"kty": "RSA",
72+
"kid": "LHVGP8kqzN1MuKRMTsroIcR-7hdicXWdpaquEWcAh9Q",
73+
"alg": "RS256",
74+
"n": "s5XuFpodwhj6my_gTUHDKbHmQIx-3Tf40OduMZRWlU6_B_nSdjX01kS1UQSGw_G5eVQARooI-tY1vj3bBwn4dEEFa2TlnNnAJca0hj2Izef8A8Uw-mT0fgGI4Hs3xS84Mn_WXNlKXEiPLiFyOGNr0GQBKZDyTps8JUlvnwuWCv1gkzudUHa8B0i8ITSEUclK9_LqZj4zXUAN0Wj_4DVfI_PQ0IHci9K5Q9bgCV0j1EvTsyrwGyLFwyhktUmNhjREAfgYmxvbIRhPSP4YuO2Et1KM7YmjA75cQ9oE3i-QLrOZDripyMRop5RmWttQCEdEWLQWPzBd7aZ5CLbmZuIlIQ",
75+
"e": "AQAB"
76+
}
77+
]
78+
}
79+
80+
permissions:
81+
contents: read
82+
```
83+
5884
This policy will allow OIDC tokens from Google accounts of folks with a
5985
Chainguard email address to federate and read the repo contents.
6086

pkg/jwks/jwks.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright 2025 Chainguard, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package jwks
5+
6+
import (
7+
"crypto"
8+
"encoding/json"
9+
10+
"github.com/coreos/go-oidc/v3/oidc"
11+
"github.com/go-jose/go-jose/v4"
12+
)
13+
14+
// ConfigOption is a function that modifies the OIDC config.
15+
type ConfigOption func(*oidc.Config)
16+
17+
// NewVerifier creates an OIDC verifier from a JWKS string.
18+
func NewVerifier(raw string, opts ...ConfigOption) (*oidc.IDTokenVerifier, error) {
19+
var jwks jose.JSONWebKeySet
20+
if err := json.Unmarshal([]byte(raw), &jwks); err != nil {
21+
return nil, err
22+
}
23+
24+
var keys []crypto.PublicKey
25+
for _, key := range jwks.Keys {
26+
keys = append(keys, key.Key)
27+
}
28+
29+
var issuerURL string // ignored
30+
keySet := &oidc.StaticKeySet{PublicKeys: keys}
31+
config := &oidc.Config{
32+
// Issuer and audience are verified later on by the trust policy.
33+
SkipIssuerCheck: true,
34+
SkipClientIDCheck: true,
35+
}
36+
for _, opt := range opts {
37+
opt(config)
38+
}
39+
40+
return oidc.NewVerifier(issuerURL, keySet, config), nil
41+
}

pkg/jwks/jwks_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright 2025 Chainguard, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package jwks_test
5+
6+
import (
7+
"context"
8+
"strings"
9+
"testing"
10+
11+
"github.com/coreos/go-oidc/v3/oidc"
12+
"github.com/octo-sts/app/pkg/jwks"
13+
)
14+
15+
func TestNewVerifier(t *testing.T) {
16+
// This is a JWKS from a kind Kubernetes cluster.
17+
const rawJWKS = `{
18+
"keys": [
19+
{
20+
"use": "sig",
21+
"kty": "RSA",
22+
"kid": "LHVGP8kqzN1MuKRMTsroIcR-7hdicXWdpaquEWcAh9Q",
23+
"alg": "RS256",
24+
"n": "s5XuFpodwhj6my_gTUHDKbHmQIx-3Tf40OduMZRWlU6_B_nSdjX01kS1UQSGw_G5eVQARooI-tY1vj3bBwn4dEEFa2TlnNnAJca0hj2Izef8A8Uw-mT0fgGI4Hs3xS84Mn_WXNlKXEiPLiFyOGNr0GQBKZDyTps8JUlvnwuWCv1gkzudUHa8B0i8ITSEUclK9_LqZj4zXUAN0Wj_4DVfI_PQ0IHci9K5Q9bgCV0j1EvTsyrwGyLFwyhktUmNhjREAfgYmxvbIRhPSP4YuO2Et1KM7YmjA75cQ9oE3i-QLrOZDripyMRop5RmWttQCEdEWLQWPzBd7aZ5CLbmZuIlIQ",
25+
"e": "AQAB"
26+
}
27+
]
28+
}`
29+
30+
// This is an expired token. We check both if the verifier errors out due to
31+
// the expiry, and also if it succeds when we skip the expiry check, which is
32+
// enough to test that the signature is correctly being verified.
33+
const rawToken = `eyJhbGciOiJSUzI1NiIsImtpZCI6IkxIVkdQOGtxek4xTXVLUk1Uc3JvSWNSLTdoZGljWFdkcGFxdUVXY0FoOVEifQ.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiXSwiZXhwIjoxNzQ0NzYzODI0LCJpYXQiOjE3NDQ3NjMyMjQsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiODU2YTA2OWItZmUyZi00OTI4LTgzNGMtOTUwNGYwMmU4MzQzIiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiIxYjg3OTNjZC02YTYyLTQ2ZmYtOWNmNy1lN2ZlOWU3Y2RiODYifX0sIm5iZiI6MTc0NDc2MzIyNCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCJ9.NF8UW6O8nqQ0HIKNxc2UuRBOZ5QRQhosS9_2zd0I9sCdE5OL6YWarYLb9-1_hDqEZkve5drvTTUx6fcgP3_mn10RKDg18mxbHL1dGHNTm3ZnfeTEw6XBndBocLs_Ytb8E_du_PozoKkEKDktVb98YTdgF-J3mhJTt_KBPNTkwSaFSzH6RDMq38LQaF-SKDcv2qzdzj8L6edUHNWZxf4UvqFLlEwVcmXjkh1XWmNQ-rvgc4oK7NGPuWQThkozrIsjlgKsG8ueFiATUx7I9SuRRGiOl4Vz6KfMUoCkeKLFfLXNRdVSP1C3KNtOOZWdlIJBye7pz-9VydB3DzkWVtsfAA`
34+
35+
t.Run("invalid jwks", func(t *testing.T) {
36+
verifier, err := jwks.NewVerifier("invalid jwks")
37+
if err == nil {
38+
t.Error("expected error, got nil")
39+
}
40+
if verifier != nil {
41+
t.Errorf("expected nil verifier, got %v", verifier)
42+
}
43+
})
44+
45+
t.Run("valid jwks and invalid token (expired)", func(t *testing.T) {
46+
verifier, err := jwks.NewVerifier(rawJWKS)
47+
if err != nil {
48+
t.Fatalf("expected no error, got %v", err)
49+
}
50+
if verifier == nil {
51+
t.Fatal("expected non-nil verifier, got nil")
52+
}
53+
54+
token, err := verifier.Verify(context.Background(), rawToken)
55+
if err == nil {
56+
t.Error("expected error, got nil")
57+
} else if !strings.Contains(err.Error(), "token is expired") {
58+
t.Errorf("expected expiry error, got %v", err)
59+
}
60+
if token != nil {
61+
t.Errorf("expected nil token, got %v", token)
62+
}
63+
})
64+
65+
t.Run("valid jwks and token (because expiry is skipped)", func(t *testing.T) {
66+
verifier, err := jwks.NewVerifier(rawJWKS, func(c *oidc.Config) { c.SkipExpiryCheck = true })
67+
if err != nil {
68+
t.Fatalf("expected no error, got %v", err)
69+
}
70+
if verifier == nil {
71+
t.Fatal("expected non-nil verifier, got nil")
72+
}
73+
74+
token, err := verifier.Verify(context.Background(), rawToken)
75+
if err != nil {
76+
t.Fatalf("expected no error, got %v", err)
77+
}
78+
if token == nil {
79+
t.Fatal("expected non-nil token, got nil")
80+
}
81+
82+
if token.Subject != "system:serviceaccount:default:default" {
83+
t.Errorf("expected subject 'system:serviceaccount:default:default', got %s", token.Subject)
84+
}
85+
})
86+
}

pkg/octosts/octosts.go

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
apiauth "chainguard.dev/sdk/auth"
3232
pboidc "chainguard.dev/sdk/proto/platform/oidc/v1"
3333
"github.com/chainguard-dev/clog"
34+
"github.com/octo-sts/app/pkg/jwks"
3435
"github.com/octo-sts/app/pkg/provider"
3536
)
3637

@@ -57,10 +58,11 @@ var (
5758
type sts struct {
5859
pboidc.UnimplementedSecurityTokenServiceServer
5960

60-
atr *ghinstallation.AppsTransport
61-
ceclient cloudevents.Client
62-
domain string
63-
metrics bool
61+
atr *ghinstallation.AppsTransport
62+
ceclient cloudevents.Client
63+
domain string
64+
metrics bool
65+
jwksConfigOpts []jwks.ConfigOption
6466
}
6567

6668
type cacheTrustPolicyKey struct {
@@ -107,22 +109,41 @@ func (s *sts) Exchange(ctx context.Context, request *pboidc.ExchangeRequest) (_
107109
}
108110
bearer := strings.TrimPrefix(auth[0], "Bearer ")
109111

110-
// Validate the Bearer token.
111-
issuer, err := apiauth.ExtractIssuer(bearer)
112+
e.InstallationID, e.TrustPolicy, err = s.lookupInstallAndTrustPolicy(ctx, request.Scope, request.Identity)
112113
if err != nil {
113-
return nil, status.Errorf(codes.InvalidArgument, "invalid bearer token: %v", err)
114+
return nil, err
114115
}
116+
clog.FromContext(ctx).Infof("trust policy: %#v", e.TrustPolicy)
115117

116-
// Fetch the provider from the cache or create a new one and add to the cache
117-
p, err := provider.Get(ctx, issuer)
118-
if err != nil {
119-
return nil, status.Errorf(codes.InvalidArgument, "unable to fetch or create the provider: %v", err)
118+
var verifier *oidc.IDTokenVerifier
119+
120+
// If a JWKS is set on the trust policy, we need to use it to validate the
121+
// incoming token. This is for supporting OIDC discovery endpoints that are
122+
// not reachable from the Internet, such as air-gapped environments.
123+
if e.TrustPolicy.JWKS != "" {
124+
var err error
125+
verifier, err = jwks.NewVerifier(e.TrustPolicy.JWKS, s.jwksConfigOpts...)
126+
if err != nil {
127+
return nil, status.Errorf(codes.InvalidArgument, "failed to parse JWKS: %v", err)
128+
}
129+
} else {
130+
// Validate the Bearer token.
131+
issuer, err := apiauth.ExtractIssuer(bearer)
132+
if err != nil {
133+
return nil, status.Errorf(codes.InvalidArgument, "invalid bearer token: %v", err)
134+
}
135+
136+
// Fetch the provider from the cache or create a new one and add to the cache
137+
p, err := provider.Get(ctx, issuer)
138+
if err != nil {
139+
return nil, status.Errorf(codes.InvalidArgument, "unable to fetch or create the provider: %v", err)
140+
}
141+
verifier = p.Verifier(&oidc.Config{
142+
// The audience is verified later on by the trust policy.
143+
SkipClientIDCheck: true,
144+
})
120145
}
121146

122-
verifier := p.Verifier(&oidc.Config{
123-
// The audience is verified later on by the trust policy.
124-
SkipClientIDCheck: true,
125-
})
126147
tok, err := verifier.Verify(ctx, bearer)
127148
if err != nil {
128149
return nil, status.Errorf(codes.Unauthenticated, "unable to validate token: %v", err)
@@ -134,12 +155,6 @@ func (s *sts) Exchange(ctx context.Context, request *pboidc.ExchangeRequest) (_
134155
Subject: tok.Subject,
135156
}
136157

137-
e.InstallationID, e.TrustPolicy, err = s.lookupInstallAndTrustPolicy(ctx, request.Scope, request.Identity)
138-
if err != nil {
139-
return nil, err
140-
}
141-
clog.FromContext(ctx).Infof("trust policy: %#v", e.TrustPolicy)
142-
143158
// Check the token against the federation rules.
144159
e.Actor, err = e.TrustPolicy.CheckToken(tok, s.domain)
145160
if err != nil {

pkg/octosts/octosts_test.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
"github.com/google/go-github/v69/github"
3838
"google.golang.org/grpc/metadata"
3939

40+
"github.com/octo-sts/app/pkg/jwks"
4041
"github.com/octo-sts/app/pkg/provider"
4142
)
4243

@@ -121,17 +122,26 @@ func TestExchange(t *testing.T) {
121122
provider.AddTestKeySetVerifier(t, iss, &oidc.StaticKeySet{
122123
PublicKeys: []crypto.PublicKey{pk.Public()},
123124
})
124-
ctx = metadata.NewIncomingContext(ctx, metadata.MD{"authorization": []string{"Bearer " + token}})
125+
signedTokenCtx := metadata.NewIncomingContext(ctx, metadata.MD{"authorization": []string{"Bearer " + token}})
126+
127+
// This is an expired token.
128+
const jwksToken = `eyJhbGciOiJSUzI1NiIsImtpZCI6IkxIVkdQOGtxek4xTXVLUk1Uc3JvSWNSLTdoZGljWFdkcGFxdUVXY0FoOVEifQ.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiXSwiZXhwIjoxNzQ0NzYzODI0LCJpYXQiOjE3NDQ3NjMyMjQsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiODU2YTA2OWItZmUyZi00OTI4LTgzNGMtOTUwNGYwMmU4MzQzIiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiIxYjg3OTNjZC02YTYyLTQ2ZmYtOWNmNy1lN2ZlOWU3Y2RiODYifX0sIm5iZiI6MTc0NDc2MzIyNCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCJ9.NF8UW6O8nqQ0HIKNxc2UuRBOZ5QRQhosS9_2zd0I9sCdE5OL6YWarYLb9-1_hDqEZkve5drvTTUx6fcgP3_mn10RKDg18mxbHL1dGHNTm3ZnfeTEw6XBndBocLs_Ytb8E_du_PozoKkEKDktVb98YTdgF-J3mhJTt_KBPNTkwSaFSzH6RDMq38LQaF-SKDcv2qzdzj8L6edUHNWZxf4UvqFLlEwVcmXjkh1XWmNQ-rvgc4oK7NGPuWQThkozrIsjlgKsG8ueFiATUx7I9SuRRGiOl4Vz6KfMUoCkeKLFfLXNRdVSP1C3KNtOOZWdlIJBye7pz-9VydB3DzkWVtsfAA`
129+
jwksTokenCtx := metadata.NewIncomingContext(ctx, metadata.MD{"authorization": []string{"Bearer " + jwksToken}})
125130

126131
sts := &sts{
127132
atr: atr,
133+
jwksConfigOpts: []jwks.ConfigOption{
134+
func(c *oidc.Config) { c.SkipExpiryCheck = true },
135+
},
128136
}
129137
for _, tc := range []struct {
138+
ctx context.Context
130139
name string
131140
req *v1.ExchangeRequest
132141
want *github.InstallationTokenOptions
133142
}{
134143
{
144+
ctx: signedTokenCtx,
135145
name: "repo",
136146
req: &v1.ExchangeRequest{
137147
Identity: "foo",
@@ -145,6 +155,7 @@ func TestExchange(t *testing.T) {
145155
},
146156
},
147157
{
158+
ctx: signedTokenCtx,
148159
name: "org",
149160
req: &v1.ExchangeRequest{
150161
Identity: "foo",
@@ -156,9 +167,23 @@ func TestExchange(t *testing.T) {
156167
},
157168
},
158169
},
170+
{
171+
ctx: jwksTokenCtx,
172+
name: "repo with jwks",
173+
req: &v1.ExchangeRequest{
174+
Identity: "jwks",
175+
Scope: "org/repo",
176+
},
177+
want: &github.InstallationTokenOptions{
178+
Repositories: []string{"repo"},
179+
Permissions: &github.InstallationPermissions{
180+
PullRequests: github.Ptr("write"),
181+
},
182+
},
183+
},
159184
} {
160185
t.Run(tc.name, func(t *testing.T) {
161-
tok, err := sts.Exchange(ctx, tc.req)
186+
tok, err := sts.Exchange(tc.ctx, tc.req)
162187
if err != nil {
163188
t.Fatalf("Exchange failed: %v", err)
164189
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Copyright 2024 Chainguard, Inc.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
issuer: https://kubernetes.default.svc.cluster.local
5+
audience: https://kubernetes.default.svc.cluster.local
6+
subject: system:serviceaccount:default:default
7+
jwks: |
8+
{
9+
"keys": [
10+
{
11+
"use": "sig",
12+
"kty": "RSA",
13+
"kid": "LHVGP8kqzN1MuKRMTsroIcR-7hdicXWdpaquEWcAh9Q",
14+
"alg": "RS256",
15+
"n": "s5XuFpodwhj6my_gTUHDKbHmQIx-3Tf40OduMZRWlU6_B_nSdjX01kS1UQSGw_G5eVQARooI-tY1vj3bBwn4dEEFa2TlnNnAJca0hj2Izef8A8Uw-mT0fgGI4Hs3xS84Mn_WXNlKXEiPLiFyOGNr0GQBKZDyTps8JUlvnwuWCv1gkzudUHa8B0i8ITSEUclK9_LqZj4zXUAN0Wj_4DVfI_PQ0IHci9K5Q9bgCV0j1EvTsyrwGyLFwyhktUmNhjREAfgYmxvbIRhPSP4YuO2Et1KM7YmjA75cQ9oE3i-QLrOZDripyMRop5RmWttQCEdEWLQWPzBd7aZ5CLbmZuIlIQ",
16+
"e": "AQAB"
17+
}
18+
]
19+
}
20+
21+
permissions:
22+
pull_requests: write

pkg/octosts/trust_policy.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ type TrustPolicy struct {
2020
IssuerPattern string `json:"issuer_pattern,omitempty"`
2121
issuerPattern *regexp.Regexp `json:"-"`
2222

23+
JWKS string `json:"jwks,omitempty"`
24+
2325
Subject string `json:"subject,omitempty"`
2426
SubjectPattern string `json:"subject_pattern,omitempty"`
2527
subjectPattern *regexp.Regexp `json:"-"`

0 commit comments

Comments
 (0)