Skip to content

Commit 8a72ecf

Browse files
committed
feat(bosh-nats-sync): verify UAA JWT RS256 signature when uaa_public_key is set
1 parent de06559 commit 8a72ecf

4 files changed

Lines changed: 214 additions & 7 deletions

File tree

src/bosh-nats-sync/pkg/authprovider/auth_provider.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ package authprovider
22

33
import (
44
"context"
5+
"crypto"
6+
"crypto/rsa"
7+
"crypto/sha256"
8+
"crypto/x509"
59
"encoding/base64"
610
"encoding/json"
11+
"encoding/pem"
712
"fmt"
813
"log/slog"
914
"net/http"
@@ -92,6 +97,16 @@ func (a *AuthProvider) uaaTokenHeader(ctx context.Context, uaaURL string) (strin
9297
if err != nil {
9398
return "", fmt.Errorf("failed to obtain token from UAA: %w", err)
9499
}
100+
101+
// Mirror Ruby UAAToken decode_options: when uaa_public_key is configured,
102+
// verify the JWT RS256 signature. This guards against a compromised token
103+
// even when the TLS channel is intact.
104+
if pubKey := strings.TrimSpace(a.config.UAAPublicKey); pubKey != "" {
105+
if err := verifyJWTRS256(tok.AccessToken, pubKey); err != nil {
106+
return "", fmt.Errorf("UAA JWT signature verification failed: %w", err)
107+
}
108+
}
109+
95110
a.token = tok
96111
return formatBearer(tok), nil
97112
}
@@ -140,6 +155,61 @@ func (a *AuthProvider) CAFilePath() string {
140155
return a.config.DirectorCACert
141156
}
142157

158+
// verifyJWTRS256 verifies that the JWT was signed with the RSA private key
159+
// corresponding to pemPublicKey. It mirrors the Ruby UAAToken decode_options
160+
// path where { pkey: @uaa_public_key, verify: true } is passed to
161+
// CF::UAA::TokenCoder.decode when a public key is configured.
162+
//
163+
// Only RS256 (RSASSA-PKCS1-v1.5 with SHA-256) is accepted because that is
164+
// the algorithm UAA uses for its signing keys.
165+
func verifyJWTRS256(jwtToken, pemPublicKey string) error {
166+
parts := strings.SplitN(jwtToken, ".", 3)
167+
if len(parts) != 3 {
168+
return fmt.Errorf("invalid JWT: expected 3 parts, got %d", len(parts))
169+
}
170+
171+
// Decode the header to confirm alg=RS256.
172+
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
173+
if err != nil {
174+
return fmt.Errorf("invalid JWT header encoding: %w", err)
175+
}
176+
var header struct {
177+
Alg string `json:"alg"`
178+
}
179+
if err := json.Unmarshal(headerBytes, &header); err != nil {
180+
return fmt.Errorf("invalid JWT header JSON: %w", err)
181+
}
182+
if header.Alg != "RS256" {
183+
return fmt.Errorf("unsupported JWT algorithm %q: only RS256 is supported", header.Alg)
184+
}
185+
186+
// Decode the signature.
187+
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
188+
if err != nil {
189+
return fmt.Errorf("invalid JWT signature encoding: %w", err)
190+
}
191+
192+
// Hash the signing input (header + "." + payload).
193+
signingInput := parts[0] + "." + parts[1]
194+
digest := sha256.Sum256([]byte(signingInput))
195+
196+
// Parse the RSA public key from PEM.
197+
block, _ := pem.Decode([]byte(pemPublicKey))
198+
if block == nil {
199+
return fmt.Errorf("failed to decode PEM block from uaa_public_key")
200+
}
201+
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
202+
if err != nil {
203+
return fmt.Errorf("failed to parse RSA public key: %w", err)
204+
}
205+
rsaPub, ok := pub.(*rsa.PublicKey)
206+
if !ok {
207+
return fmt.Errorf("uaa_public_key is not an RSA public key")
208+
}
209+
210+
return rsa.VerifyPKCS1v15(rsaPub, crypto.SHA256, digest[:], sig)
211+
}
212+
143213
func formatBearer(tok *oauth2.Token) string {
144214
return "Bearer " + tok.AccessToken
145215
}

src/bosh-nats-sync/pkg/authprovider/auth_provider_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package authprovider_test
22

33
import (
44
"context"
5+
"crypto"
56
"crypto/ecdsa"
67
"crypto/elliptic"
78
"crypto/rand"
9+
"crypto/rsa"
10+
"crypto/sha256"
811
"crypto/x509"
912
"crypto/x509/pkix"
1013
"encoding/base64"
@@ -25,6 +28,37 @@ import (
2528
"bosh-nats-sync/pkg/config"
2629
)
2730

31+
// generateRSAKeyPair creates a 2048-bit RSA key pair for JWT signing tests.
32+
func generateRSAKeyPair() (*rsa.PrivateKey, []byte) {
33+
priv, err := rsa.GenerateKey(rand.Reader, 2048)
34+
if err != nil {
35+
panic(err)
36+
}
37+
pubBytes, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
38+
if err != nil {
39+
panic(err)
40+
}
41+
pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
42+
return priv, pubPEM
43+
}
44+
45+
// createRS256JWT builds a minimal RS256-signed JWT for use in tests.
46+
func createRS256JWT(privKey *rsa.PrivateKey, expiresAt time.Time) string {
47+
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
48+
claimsJSON, _ := json.Marshal(map[string]interface{}{
49+
"sub": "test-client",
50+
"exp": expiresAt.Unix(),
51+
})
52+
payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
53+
sigInput := header + "." + payload
54+
digest := sha256.Sum256([]byte(sigInput))
55+
sig, err := rsa.SignPKCS1v15(rand.Reader, privKey, crypto.SHA256, digest[:])
56+
if err != nil {
57+
panic(err)
58+
}
59+
return sigInput + "." + base64.RawURLEncoding.EncodeToString(sig)
60+
}
61+
2862
// generateTestCACertPEM creates a minimal self-signed CA certificate PEM for use in tests.
2963
func generateTestCACertPEM() []byte {
3064
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@@ -443,6 +477,101 @@ var _ = Describe("AuthProvider", func() {
443477
})
444478
})
445479

480+
Context("when uaa_public_key is configured (JWT RS256 verification)", func() {
481+
// Mirrors Ruby UAAToken decode_options: when uaa_public_key is non-empty,
482+
// the fetched token's RS256 signature is verified against the public key.
483+
var (
484+
rsaPrivKey *rsa.PrivateKey
485+
pubKeyPEM []byte
486+
)
487+
488+
BeforeEach(func() {
489+
rsaPrivKey, pubKeyPEM = generateRSAKeyPair()
490+
})
491+
492+
It("accepts a token whose RS256 signature matches uaa_public_key", func() {
493+
validJWT := createRS256JWT(rsaPrivKey, time.Now().Add(time.Hour))
494+
uaaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
495+
w.Header().Set("Content-Type", "application/json")
496+
fmt.Fprintf(w, `{"access_token":%q,"token_type":"bearer","expires_in":3600}`, validJWT)
497+
}))
498+
defer uaaServer.Close()
499+
500+
info := authprovider.InfoResponse{
501+
UserAuthentication: &authprovider.UserAuthentication{
502+
Type: "uaa",
503+
Options: authprovider.UAAOptions{URL: uaaServer.URL},
504+
},
505+
}
506+
cfg := config.DirectorConfig{
507+
ClientID: "fake-client",
508+
ClientSecret: "fake-secret",
509+
UAAPublicKey: string(pubKeyPEM),
510+
}
511+
provider := authprovider.New(info, cfg, logger)
512+
513+
header, err := provider.AuthHeader(context.Background())
514+
Expect(err).NotTo(HaveOccurred())
515+
Expect(header).To(Equal("Bearer " + validJWT))
516+
})
517+
518+
It("rejects a token signed with a different RSA key", func() {
519+
otherKey, _ := generateRSAKeyPair()
520+
wrongJWT := createRS256JWT(otherKey, time.Now().Add(time.Hour))
521+
522+
uaaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
523+
w.Header().Set("Content-Type", "application/json")
524+
fmt.Fprintf(w, `{"access_token":%q,"token_type":"bearer","expires_in":3600}`, wrongJWT)
525+
}))
526+
defer uaaServer.Close()
527+
528+
info := authprovider.InfoResponse{
529+
UserAuthentication: &authprovider.UserAuthentication{
530+
Type: "uaa",
531+
Options: authprovider.UAAOptions{URL: uaaServer.URL},
532+
},
533+
}
534+
cfg := config.DirectorConfig{
535+
ClientID: "fake-client",
536+
ClientSecret: "fake-secret",
537+
UAAPublicKey: string(pubKeyPEM),
538+
}
539+
provider := authprovider.New(info, cfg, logger)
540+
541+
_, err := provider.AuthHeader(context.Background())
542+
Expect(err).To(HaveOccurred())
543+
Expect(err.Error()).To(ContainSubstring("UAA JWT signature verification failed"))
544+
})
545+
546+
It("skips JWT verification when uaa_public_key is empty (matching Ruby verify:false path)", func() {
547+
// With an empty uaa_public_key the token is used as-is regardless of
548+
// whether it is a valid JWT, matching Ruby's { verify: false } behaviour.
549+
uaaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
550+
w.Header().Set("Content-Type", "application/json")
551+
// Return a plain opaque string, not a JWT — verification must be skipped.
552+
fmt.Fprint(w, `{"access_token":"opaque-token","token_type":"bearer","expires_in":3600}`)
553+
}))
554+
defer uaaServer.Close()
555+
556+
info := authprovider.InfoResponse{
557+
UserAuthentication: &authprovider.UserAuthentication{
558+
Type: "uaa",
559+
Options: authprovider.UAAOptions{URL: uaaServer.URL},
560+
},
561+
}
562+
cfg := config.DirectorConfig{
563+
ClientID: "fake-client",
564+
ClientSecret: "fake-secret",
565+
UAAPublicKey: "",
566+
}
567+
provider := authprovider.New(info, cfg, logger)
568+
569+
header, err := provider.AuthHeader(context.Background())
570+
Expect(err).NotTo(HaveOccurred())
571+
Expect(header).To(Equal("Bearer opaque-token"))
572+
})
573+
})
574+
446575
Context("when director is in non-UAA mode", func() {
447576
It("returns Basic authentication string with username and password", func() {
448577
info := authprovider.InfoResponse{}

src/bosh-nats-sync/pkg/config/config.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,20 @@ import (
99
)
1010

1111
type DirectorConfig struct {
12-
URL string `yaml:"url"`
13-
User string `yaml:"user"`
14-
Password string `yaml:"password"`
15-
ClientID string `yaml:"client_id"`
16-
ClientSecret string `yaml:"client_secret"`
17-
DirectorCACert string `yaml:"director_ca_cert"`
18-
UAACACert string `yaml:"uaa_ca_cert"`
12+
URL string `yaml:"url"`
13+
User string `yaml:"user"`
14+
Password string `yaml:"password"`
15+
ClientID string `yaml:"client_id"`
16+
ClientSecret string `yaml:"client_secret"`
17+
DirectorCACert string `yaml:"director_ca_cert"`
18+
UAACACert string `yaml:"uaa_ca_cert"`
19+
// UAAPublicKey is the PEM-encoded RSA public key used to verify UAA JWT
20+
// signatures. When non-empty the token's RS256 signature is verified after
21+
// each fetch, mirroring the Ruby UAAToken decode_options behaviour:
22+
// { pkey: @uaa_public_key, verify: true }.
23+
// When empty, signature verification is skipped (the TLS channel already
24+
// authenticates the token source), matching Ruby's { verify: false } path.
25+
UAAPublicKey string `yaml:"uaa_public_key"`
1926
DirectorSubjectFile string `yaml:"director_subject_file"`
2027
HMSubjectFile string `yaml:"hm_subject_file"`
2128
ConnectionWaitTimeout int `yaml:"connection_wait_timeout"`

src/bosh-nats-sync/testdata/sample_config.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ director:
88
client_secret: client_secret
99
director_ca_cert: director_ca_cert
1010
uaa_ca_cert: uaa_ca_cert
11+
uaa_public_key: ''
1112
director_subject_file: /var/vcap/data/nats/director-subject
1213
hm_subject_file: /var/vcap/data/nats/hm-subject
1314

0 commit comments

Comments
 (0)