mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +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)
|
slog.Error("config invalid", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
verifier = iam.NewJWKS(cfg.IAMUrl)
|
verifier = iam.NewJWKS(cfg.IAMUrl, cfg.IAMClientID)
|
||||||
slog.Info("auth: IAM JWKS verifier", "issuer", cfg.IAMUrl)
|
slog.Info("auth: IAM JWKS verifier", "issuer", cfg.IAMUrl, "audience", cfg.IAMClientID)
|
||||||
} else {
|
} else {
|
||||||
verifier = iam.NewHS(cfg.JWTSecret)
|
verifier = iam.NewHS(cfg.JWTSecret)
|
||||||
slog.Info("auth: HS256 dev verifier")
|
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`.
|
// re-fetched at most once per minTTL when a token presents an unknown `kid`.
|
||||||
type JWKSVerifier struct {
|
type JWKSVerifier struct {
|
||||||
issuer string
|
issuer string
|
||||||
|
audience string // expected `aud` (clientId); "" skips the audience check
|
||||||
clockSkew time.Duration
|
clockSkew time.Duration
|
||||||
http *http.Client
|
http *http.Client
|
||||||
|
|
||||||
@@ -55,10 +56,14 @@ type JWKSVerifier struct {
|
|||||||
minRefresh time.Duration
|
minRefresh time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewJWKS constructs a JWKS verifier for the given OIDC issuer.
|
// NewJWKS constructs a JWKS verifier for the given OIDC issuer. When audience is
|
||||||
func NewJWKS(issuer string) *JWKSVerifier {
|
// 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{
|
return &JWKSVerifier{
|
||||||
issuer: issuer,
|
issuer: issuer,
|
||||||
|
audience: audience,
|
||||||
clockSkew: 60 * time.Second,
|
clockSkew: 60 * time.Second,
|
||||||
http: &http.Client{Timeout: 10 * time.Second},
|
http: &http.Client{Timeout: 10 * time.Second},
|
||||||
keys: map[string]any{},
|
keys: map[string]any{},
|
||||||
@@ -84,8 +89,7 @@ func (j *JWKSVerifier) Verify(ctx context.Context, token string) (*Claims, error
|
|||||||
}
|
}
|
||||||
return key, nil
|
return key, nil
|
||||||
},
|
},
|
||||||
jwt.WithLeeway(j.clockSkew),
|
opts(j)...,
|
||||||
jwt.WithIssuer(j.issuer),
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
@@ -101,6 +105,19 @@ func (j *JWKSVerifier) Verify(ctx context.Context, token string) (*Claims, error
|
|||||||
return claims, nil
|
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
|
// keyByID returns the public key for kid, refreshing the JWKS once (subject to
|
||||||
// minRefresh) if the kid is not cached.
|
// minRefresh) if the kid is not cached.
|
||||||
func (j *JWKSVerifier) keyByID(ctx context.Context, kid string) (any, error) {
|
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)
|
t.Fatalf("genkey: %v", err)
|
||||||
}
|
}
|
||||||
srv := newJWKSServer(t, key, "cert-test")
|
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))
|
tokStr := signRS256(t, key, "cert-test", srv.URL, time.Now().Add(time.Hour))
|
||||||
claims, err := v.Verify(context.Background(), tokStr)
|
claims, err := v.Verify(context.Background(), tokStr)
|
||||||
@@ -85,7 +85,7 @@ func TestJWKSVerify_ValidRS256(t *testing.T) {
|
|||||||
func TestJWKSVerify_RejectsHS256(t *testing.T) {
|
func TestJWKSVerify_RejectsHS256(t *testing.T) {
|
||||||
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
srv := newJWKSServer(t, key, "cert-test")
|
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
|
// HMAC-signed token must be rejected: a JWKS verifier accepts only the
|
||||||
// asymmetric algs IAM signs with (RS/ES), never a caller-chosen HS256.
|
// 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) {
|
func TestJWKSVerify_RejectsWrongIssuer(t *testing.T) {
|
||||||
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
srv := newJWKSServer(t, key, "cert-test")
|
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))
|
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) {
|
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) {
|
func TestJWKSVerify_RejectsExpired(t *testing.T) {
|
||||||
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
srv := newJWKSServer(t, key, "cert-test")
|
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))
|
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) {
|
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) {
|
func TestJWKSVerify_RejectsUnknownKID(t *testing.T) {
|
||||||
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
srv := newJWKSServer(t, key, "cert-test")
|
srv := newJWKSServer(t, key, "cert-test")
|
||||||
v := NewJWKS(srv.URL)
|
v := NewJWKS(srv.URL, "")
|
||||||
|
|
||||||
// Token signed with a kid the JWKS does not publish.
|
// Token signed with a kid the JWKS does not publish.
|
||||||
tokStr := signRS256(t, key, "cert-unknown", srv.URL, time.Now().Add(time.Hour))
|
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)
|
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