snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
115
internal/jwtverify/token_verifier_jwt_jwks_scope_test.go
Normal file
115
internal/jwtverify/token_verifier_jwt_jwks_scope_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package jwtverify
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/config"
|
||||
|
||||
"github.com/cristalhq/jwt/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestJWKSCacheKeyIsScopedToTemplatedEndpoint is a regression test for a
|
||||
// cross-issuer JWT verification bypass. With templated JWKS endpoints
|
||||
// (e.g., per-tenant URLs derived from the JWT iss claim), the JWKS cache
|
||||
// must scope entries by the resolved endpoint, not only by the JWT header
|
||||
// kid. Otherwise, a key cached from tenant A's endpoint could be used to
|
||||
// verify a token forged for tenant B if both JWKS documents advertise the
|
||||
// same kid — kid is not globally unique by spec.
|
||||
//
|
||||
// Scenario: an attacker controls tenant A and mints a token claiming
|
||||
// issuer "tenant-b" but signed with tenant A's key. Tenant A's key is
|
||||
// already cached. The verifier must NOT accept the forged token.
|
||||
func TestJWKSCacheKeyIsScopedToTemplatedEndpoint(t *testing.T) {
|
||||
const kid = "shared-kid"
|
||||
|
||||
tenantAPrivateKey, tenantAPublicKey := generateTestRSAKeys(t)
|
||||
_, tenantBPublicKey := generateTestRSAKeys(t)
|
||||
|
||||
var tenantARequests, tenantBRequests int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/tenant-a/jwks.json":
|
||||
atomic.AddInt32(&tenantARequests, 1)
|
||||
writeRSAJWKS(t, w, tenantAPublicKey, kid)
|
||||
case "/tenant-b/jwks.json":
|
||||
atomic.AddInt32(&tenantBRequests, 1)
|
||||
writeRSAJWKS(t, w, tenantBPublicKey, kid)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
verifier, err := NewTokenVerifierJWT(VerifierConfig{
|
||||
JWKSPublicEndpoint: ts.URL + "/{{tenant}}/jwks.json",
|
||||
IssuerRegex: `^(?P<tenant>tenant-a|tenant-b)$`,
|
||||
}, cfgContainer)
|
||||
require.NoError(t, err)
|
||||
|
||||
legitimateTenantAToken := getRSAIssuerConnToken(t, "tenant-a-user", "tenant-a", tenantAPrivateKey, kid)
|
||||
forgedTenantBToken := getRSAIssuerConnToken(t, "victim", "tenant-b", tenantAPrivateKey, kid)
|
||||
|
||||
// Step 1: Legitimate tenant-A token primes tenant A's key in the cache.
|
||||
ct, err := verifier.VerifyConnectToken(legitimateTenantAToken, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "tenant-a-user", ct.UserID)
|
||||
require.Equal(t, int32(1), atomic.LoadInt32(&tenantARequests))
|
||||
require.Equal(t, int32(0), atomic.LoadInt32(&tenantBRequests))
|
||||
|
||||
// Step 2: Forged tenant-B token (signed by tenant A) must be rejected.
|
||||
// The verifier must consult tenant B's JWKS endpoint, fetch tenant B's
|
||||
// public key, and reject the signature.
|
||||
_, err = verifier.VerifyConnectToken(forgedTenantBToken, false)
|
||||
require.Error(t, err, "forged cross-tenant token must be rejected")
|
||||
require.ErrorIs(t, err, ErrInvalidToken)
|
||||
require.Equal(t, int32(1), atomic.LoadInt32(&tenantBRequests),
|
||||
"verifier must fetch tenant B's JWKS rather than reuse cached tenant A key")
|
||||
}
|
||||
|
||||
func writeRSAJWKS(t *testing.T, w http.ResponseWriter, pubKey *rsa.PublicKey, kid string) {
|
||||
t.Helper()
|
||||
resp := map[string]any{
|
||||
"keys": []map[string]string{
|
||||
{
|
||||
"alg": "RS256",
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"kid": kid,
|
||||
"n": encodeToString(pubKey.N.Bytes()),
|
||||
"e": encodeUint64ToString(uint64(pubKey.E)),
|
||||
},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
require.NoError(t, json.NewEncoder(w).Encode(resp))
|
||||
}
|
||||
|
||||
func getRSAIssuerConnToken(t *testing.T, user, issuer string, rsaPrivateKey *rsa.PrivateKey, kid string) string {
|
||||
t.Helper()
|
||||
signer, err := jwt.NewSignerRS(jwt.RS256, rsaPrivateKey)
|
||||
require.NoError(t, err)
|
||||
builder := jwt.NewBuilder(signer, jwt.WithKeyID(kid))
|
||||
claims := &ConnectTokenClaims{
|
||||
Base64Info: "e30=",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: user,
|
||||
Issuer: issuer,
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
},
|
||||
}
|
||||
token, err := builder.Build(claims)
|
||||
require.NoError(t, err)
|
||||
return token.String()
|
||||
}
|
||||
Reference in New Issue
Block a user