mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
harden(api): enforce JWT audience when IAM_CLIENT_ID is set
Adversarial review follow-up: without an aud check, a token minted for ANY client of the same IAM issuer would be accepted. NewJWKS now takes the expected audience (the deployment's IAM_CLIENT_ID); when set, tokens whose aud does not match are rejected (token minted for a different client is no longer accepted). Empty audience preserves issuer+signature-only behavior. Test: matching aud passes, mismatched aud rejected.
This commit is contained in:
@@ -50,8 +50,8 @@ func main() {
|
||||
slog.Error("config invalid", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
verifier = iam.NewJWKS(cfg.IAMUrl)
|
||||
slog.Info("auth: IAM JWKS verifier", "issuer", cfg.IAMUrl)
|
||||
verifier = iam.NewJWKS(cfg.IAMUrl, cfg.IAMClientID)
|
||||
slog.Info("auth: IAM JWKS verifier", "issuer", cfg.IAMUrl, "audience", cfg.IAMClientID)
|
||||
} else {
|
||||
verifier = iam.NewHS(cfg.JWTSecret)
|
||||
slog.Info("auth: HS256 dev verifier")
|
||||
|
||||
@@ -46,6 +46,7 @@ func ecCurve(crv string) (elliptic.Curve, error) {
|
||||
// re-fetched at most once per minTTL when a token presents an unknown `kid`.
|
||||
type JWKSVerifier struct {
|
||||
issuer string
|
||||
audience string // expected `aud` (clientId); "" skips the audience check
|
||||
clockSkew time.Duration
|
||||
http *http.Client
|
||||
|
||||
@@ -55,10 +56,14 @@ type JWKSVerifier struct {
|
||||
minRefresh time.Duration
|
||||
}
|
||||
|
||||
// NewJWKS constructs a JWKS verifier for the given OIDC issuer.
|
||||
func NewJWKS(issuer string) *JWKSVerifier {
|
||||
// NewJWKS constructs a JWKS verifier for the given OIDC issuer. When audience is
|
||||
// non-empty (the deployment's IAM client ID), tokens whose `aud` does not match
|
||||
// are rejected — so a token minted for a different client of the same issuer is
|
||||
// not accepted. Empty audience skips the check (issuer + signature only).
|
||||
func NewJWKS(issuer, audience string) *JWKSVerifier {
|
||||
return &JWKSVerifier{
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
clockSkew: 60 * time.Second,
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
keys: map[string]any{},
|
||||
@@ -84,8 +89,7 @@ func (j *JWKSVerifier) Verify(ctx context.Context, token string) (*Claims, error
|
||||
}
|
||||
return key, nil
|
||||
},
|
||||
jwt.WithLeeway(j.clockSkew),
|
||||
jwt.WithIssuer(j.issuer),
|
||||
opts(j)...,
|
||||
)
|
||||
if err != nil {
|
||||
switch {
|
||||
@@ -101,6 +105,19 @@ func (j *JWKSVerifier) Verify(ctx context.Context, token string) (*Claims, error
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// opts builds the jwt parser options: leeway + issuer always, audience only
|
||||
// when configured.
|
||||
func opts(j *JWKSVerifier) []jwt.ParserOption {
|
||||
o := []jwt.ParserOption{
|
||||
jwt.WithLeeway(j.clockSkew),
|
||||
jwt.WithIssuer(j.issuer),
|
||||
}
|
||||
if j.audience != "" {
|
||||
o = append(o, jwt.WithAudience(j.audience))
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// keyByID returns the public key for kid, refreshing the JWKS once (subject to
|
||||
// minRefresh) if the kid is not cached.
|
||||
func (j *JWKSVerifier) keyByID(ctx context.Context, kid string) (any, error) {
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestJWKSVerify_ValidRS256(t *testing.T) {
|
||||
t.Fatalf("genkey: %v", err)
|
||||
}
|
||||
srv := newJWKSServer(t, key, "cert-test")
|
||||
v := NewJWKS(srv.URL)
|
||||
v := NewJWKS(srv.URL, "")
|
||||
|
||||
tokStr := signRS256(t, key, "cert-test", srv.URL, time.Now().Add(time.Hour))
|
||||
claims, err := v.Verify(context.Background(), tokStr)
|
||||
@@ -85,7 +85,7 @@ func TestJWKSVerify_ValidRS256(t *testing.T) {
|
||||
func TestJWKSVerify_RejectsHS256(t *testing.T) {
|
||||
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
srv := newJWKSServer(t, key, "cert-test")
|
||||
v := NewJWKS(srv.URL)
|
||||
v := NewJWKS(srv.URL, "")
|
||||
|
||||
// HMAC-signed token must be rejected: a JWKS verifier accepts only the
|
||||
// asymmetric algs IAM signs with (RS/ES), never a caller-chosen HS256.
|
||||
@@ -99,7 +99,7 @@ func TestJWKSVerify_RejectsHS256(t *testing.T) {
|
||||
func TestJWKSVerify_RejectsWrongIssuer(t *testing.T) {
|
||||
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
srv := newJWKSServer(t, key, "cert-test")
|
||||
v := NewJWKS(srv.URL)
|
||||
v := NewJWKS(srv.URL, "")
|
||||
|
||||
tokStr := signRS256(t, key, "cert-test", "https://evil.example", time.Now().Add(time.Hour))
|
||||
if _, err := v.Verify(context.Background(), tokStr); !errors.Is(err, ErrTokenInvalid) {
|
||||
@@ -110,7 +110,7 @@ func TestJWKSVerify_RejectsWrongIssuer(t *testing.T) {
|
||||
func TestJWKSVerify_RejectsExpired(t *testing.T) {
|
||||
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
srv := newJWKSServer(t, key, "cert-test")
|
||||
v := NewJWKS(srv.URL)
|
||||
v := NewJWKS(srv.URL, "")
|
||||
|
||||
tokStr := signRS256(t, key, "cert-test", srv.URL, time.Now().Add(-2*time.Minute))
|
||||
if _, err := v.Verify(context.Background(), tokStr); !errors.Is(err, ErrTokenExpired) {
|
||||
@@ -121,7 +121,7 @@ func TestJWKSVerify_RejectsExpired(t *testing.T) {
|
||||
func TestJWKSVerify_RejectsUnknownKID(t *testing.T) {
|
||||
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
srv := newJWKSServer(t, key, "cert-test")
|
||||
v := NewJWKS(srv.URL)
|
||||
v := NewJWKS(srv.URL, "")
|
||||
|
||||
// Token signed with a kid the JWKS does not publish.
|
||||
tokStr := signRS256(t, key, "cert-unknown", srv.URL, time.Now().Add(time.Hour))
|
||||
@@ -129,3 +129,37 @@ func TestJWKSVerify_RejectsUnknownKID(t *testing.T) {
|
||||
t.Fatalf("expected ErrTokenInvalid for unknown kid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func signRS256Aud(t *testing.T, key *rsa.PrivateKey, kid, issuer, aud string) string {
|
||||
t.Helper()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
||||
"sub": "user-123",
|
||||
"iss": issuer,
|
||||
"aud": aud,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
tok.Header["kid"] = kid
|
||||
s, err := tok.SignedString(key)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestJWKSVerify_Audience(t *testing.T) {
|
||||
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
srv := newJWKSServer(t, key, "cert-test")
|
||||
v := NewJWKS(srv.URL, "bootnode-platform")
|
||||
|
||||
// Matching aud is accepted.
|
||||
ok := signRS256Aud(t, key, "cert-test", srv.URL, "bootnode-platform")
|
||||
if _, err := v.Verify(context.Background(), ok); err != nil {
|
||||
t.Fatalf("expected matching aud to pass, got %v", err)
|
||||
}
|
||||
|
||||
// A token minted for a different client of the same issuer is rejected.
|
||||
bad := signRS256Aud(t, key, "cert-test", srv.URL, "some-other-client")
|
||||
if _, err := v.Verify(context.Background(), bad); !errors.Is(err, ErrTokenInvalid) {
|
||||
t.Fatalf("expected ErrTokenInvalid for wrong aud, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user