Files
upstream-centrifugo/internal/jwtverify/token_verifier_jwt_jwks_scope_test.go
upstream-mirror b352a9a30d
Some checks failed
test / Lint (push) Failing after 3s
test / Test with Go 1.26.8 (push) Failing after 2s
snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:17:21 +05:00

116 lines
3.9 KiB
Go

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()
}