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:
2026-06-25 14:00:06 -07:00
parent ed27226e99
commit 49eb8cb337
3 changed files with 62 additions and 11 deletions
+2 -2
View File
@@ -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")
+21 -4
View File
@@ -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) {
+39 -5
View File
@@ -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)
}
}