Skip to content

feat(MCD): implement multiple custom domains support#368

Open
developerkunal wants to merge 15 commits intomasterfrom
feat/mcd-multiple-custom-domains
Open

feat(MCD): implement multiple custom domains support#368
developerkunal wants to merge 15 commits intomasterfrom
feat/mcd-multiple-custom-domains

Conversation

@developerkunal
Copy link
Copy Markdown
Contributor

@developerkunal developerkunal commented Jan 15, 2026

📝 Checklist

  • All new/changed/fixed functionality is covered by tests (or N/A)
  • I have added documentation for all new/changed functionality (or N/A)

🔧 Changes

Implements Multiple Custom Domains (MCD) support — enabling multi-tenant applications to validate JWTs from multiple OIDC issuers, with symmetric key support and algorithm policy enforcement.

Types and Methods Added

JWKS Package — MultiIssuerProvider:

  • NewMultiIssuerProvider(opts...) — Creates multi-issuer JWKS provider with dynamic per-issuer routing
  • MultiIssuerProvider.KeyFunc(ctx) — Routes JWKS requests to correct issuer based on context
  • MultiIssuerProvider.ProviderCount() — Total managed issuers (OIDC + symmetric)
  • MultiIssuerProvider.Stats() — Per-issuer observability (type, algorithm, last-used time)
  • IssuerKeyConfig — Per-issuer symmetric key configuration (Secret, Algorithm, KeyID)
  • ProviderStats, IssuerInfo, IssuerType — Observability types

JWKS Package — New Options:

  • WithMultiIssuerCacheTTL(ttl) — Cache refresh interval (default: 15 min)
  • WithMultiIssuerHTTPClient(client) — Custom HTTP client
  • WithMultiIssuerCache(cache) — Custom cache implementation (e.g., Redis)
  • WithMaxProviders(max) — LRU eviction limit (default: 100)
  • WithIssuerKeyConfig(issuer, config) — Per-issuer symmetric key
  • WithIssuerKeyConfigs(configs) — Batch symmetric key configuration

Validator Package — New Options:

  • WithIssuers(issuers) — Accept tokens from multiple static issuers
  • WithIssuersResolver(resolver) — Dynamic issuer resolution at request time
  • WithAlgorithms(algorithms) — Multiple allowed signature algorithms

Validator Package — Context Helpers:

  • IssuerFromContext(ctx) — Extract validated issuer from context
  • SetIssuerInContext(ctx, issuer) — Store issuer in context

Types and Methods Changed

  • Validator.ValidateToken — Now uses jws.Parse for single-pass header extraction; validates token alg against allowed list before JWKS fetch
  • Validator.parseToken — Uses jws.WithUseDefault(true) for jwk.Set to support symmetric keys without kid; returns clear error when multiple algorithms configured with raw key
  • Validator.validate() — Checks allowedAlgorithms slice instead of single signatureAlgorithm
  • WithAlgorithm — Now writes to allowedAlgorithms slice; mutually exclusive with WithAlgorithms

Internal Field Changed (Non-Breaking)

  • Validator.signatureAlgorithm (unexported) → Validator.allowedAlgorithms (unexported slice)

Summary of Usage

Static Multiple Issuers:

provider, _ := jwks.NewMultiIssuerProvider(
    jwks.WithMultiIssuerCacheTTL(5*time.Minute),
)

v, _ := validator.New(
    validator.WithKeyFunc(provider.KeyFunc),
    validator.WithAlgorithm(validator.RS256),
    validator.WithIssuers([]string{
        "https://tenant1.auth0.com/",
        "https://tenant2.auth0.com/",
    }),
    validator.WithAudience("my-api"),
)

Mixed Symmetric + Asymmetric MCD:

provider, _ := jwks.NewMultiIssuerProvider(
    jwks.WithIssuerKeyConfig("https://symmetric.example.com/", jwks.IssuerKeyConfig{
        Secret:    []byte("shared-secret"),
        Algorithm: validator.HS256,
    }),
)

v, _ := validator.New(
    validator.WithKeyFunc(provider.KeyFunc),
    validator.WithAlgorithms([]validator.SignatureAlgorithm{validator.RS256, validator.HS256}),
    validator.WithIssuers([]string{
        "https://tenant1.auth0.com/",        // RS256 via OIDC
        "https://symmetric.example.com/",     // HS256 via pre-shared secret
    }),
    validator.WithAudience("my-api"),
)

Using IssuerFromContext in Request Handlers:

After token validation, the validated issuer is available in the request context. This lets handlers implement per-tenant logic:

func handler(w http.ResponseWriter, r *http.Request) {
    // Extract the validated issuer set by ValidateToken
    issuer, ok := validator.IssuerFromContext(r.Context())
    if !ok {
        http.Error(w, "issuer not found", http.StatusUnauthorized)
        return
    }

    // Route logic based on which tenant issued the token
    switch issuer {
    case "https://tenant1.auth0.com/":
        // tenant1-specific logic
    case "https://tenant2.auth0.com/":
        // tenant2-specific logic
    }

    // Or use it for logging / metrics
    log.Printf("Request authenticated via issuer: %s", issuer)
}

Dynamic Issuer Resolution with WithIssuersResolver:

For applications where the allowed issuers are not known at startup — e.g., multi-tenant SaaS where tenants are stored in a database:

provider, _ := jwks.NewMultiIssuerProvider(
    jwks.WithMultiIssuerCacheTTL(5*time.Minute),
)

v, _ := validator.New(
    validator.WithKeyFunc(provider.KeyFunc),
    validator.WithAlgorithm(validator.RS256),
    validator.WithIssuersResolver(func(ctx context.Context) ([]string, error) {
        // The unverified issuer from the token is available in context.
        // Use it to scope your database lookup instead of loading all issuers.
        tokenIssuer, _ := validator.IssuerFromContext(ctx)

        // Verify this issuer exists in your tenant database
        tenant, err := db.GetTenantByIssuer(ctx, tokenIssuer)
        if err != nil {
            return nil, fmt.Errorf("unknown tenant: %w", err)
        }

        // Return the allowed issuers for this tenant
        return tenant.AllowedIssuers, nil
    }),
    validator.WithAudience("my-api"),
)

The resolver is called on every request with the token's unverified issuer already in context. This means you can do a targeted database lookup (WHERE issuer = ?) instead of loading all issuers. The resolver must be:

  • Thread-safe (called concurrently)
  • Fast (< 5ms recommended — cache if needed)
  • Secure (the issuer from context is unverified at this point)

Tenant Monitoring:

stats := provider.Stats()
log.Printf("Managing %d issuers (%d OIDC, %d symmetric)",
    stats.Total, stats.OIDC, stats.Symmetric)
for _, info := range stats.Issuers {
    log.Printf("  %s: type=%s alg=%s lastUsed=%v",
        info.Issuer, info.Type, info.Algorithm, info.LastUsed)
}

📚 References

🔬 Testing

Coverage: validator 97.1%, jwks 96.2%

Algorithm enforcement (#379):

  • Tokens with disallowed alg header rejected before JWKS fetch
  • WithAlgorithms accepts multiple algorithms; mutual exclusivity with WithAlgorithm tested
  • Multi-alg + raw key returns clear error

Symmetric MCD:

  • WithIssuerKeyConfig returns static jwk.Set, bypasses OIDC discovery
  • Mixed mode: symmetric + asymmetric issuers in same provider
  • Config validation: empty config, missing secret, asymmetric alg + secret
  • Cross-issuer secret attack rejected
  • 20+ concurrent symmetric requests

Observability:

  • ProviderCount includes symmetric issuers
  • Stats() returns correct per-issuer breakdown (type, algorithm, last-used)

No regressions: All existing tests pass across all packages and all 12 example test suites.

Manual testing:

go test ./validator/... -v -count=1
go test ./jwks/... -v -count=1
go test ./... -v -count=1

  Implement complete multi-issuer/multi-tenant support for applications with multiple custom domains:

  MultiIssuerProvider:
  - Automatic JWKS routing per issuer from request context
  - Lazy provider creation with double-checked locking pattern
  - Thread-safe concurrent access across multiple issuers
  - LRU eviction with WithMaxProviders() for memory control
  - Custom cache support with WithMultiIssuerCache() (Redis, etc.)

  Validator Enhancements:
  - WithIssuers() for static multiple issuer lists
  - WithIssuersResolver() for dynamic issuer resolution
  - Issuer context management for provider routing

  JWKS Package:
  - Consolidated option.go for all provider options
  - MultiIssuerProvider with configurable TTL and HTTP client
  - Per-issuer provider caching with LRU eviction
  - Redis cache support for 100+ tenant scenarios

  Examples:
  - http-multi-issuer-example: Static issuer configuration
  - http-dynamic-issuer-example: Dynamic issuer resolution
  - http-multi-issuer-redis-example: Large-scale Redis + LRU

  Testing & CI:
  - 96.8% code coverage maintained
  - Integration tests for all examples
  - CI workflow automation for example validation

  Documentation:
  - Complete multi-tenant guide in README
  - Scaling recommendations for different tenant counts
  - Performance benchmarks and best practices
  - Enhanced package documentation
Copilot AI review requested due to automatic review settings January 15, 2026 09:44
@developerkunal developerkunal requested a review from a team as a code owner January 15, 2026 09:44
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements multi-issuer/multi-tenant support for applications that need to validate JWTs from multiple OIDC issuers (Multiple Custom Domains).

Changes:

  • Adds MultiIssuerProvider for automatic per-issuer JWKS routing with lazy provider creation, LRU eviction, and custom cache support
  • Migrates from go-jose/go-jose.v2 to lestrrat-go/jwx/v3 for JWKS handling
  • Introduces DPoP (Demonstrating Proof-of-Possession) support with trusted proxy configuration and multiple operation modes

Reviewed changes

Copilot reviewed 80 out of 112 changed files in this pull request and generated no comments.

Show a summary per file
File Description
jwks/provider.go Refactored JWKS provider to use jwx library, added caching layer with background refresh
jwks/option.go New options for provider configuration supporting multi-issuer and caching customization
jwks/multi_issuer_provider_test.go Comprehensive tests for multi-issuer provider including concurrency and LRU eviction
jwks/multi_issuer_provider.go Core implementation of multi-issuer provider with double-checked locking and LRU cache
jwks/doc.go Package documentation explaining provider types, usage patterns, and performance guidance
internal/oidc/oidc.go Minor change to defer function for response body close
internal/oidc/doc.go New documentation for OIDC discovery functionality
go.mod Updated to v3 module path and migrated to jwx library
extractor_test.go Enhanced tests for token extraction including DPoP scheme support
extractor.go Extended token extractor to support DPoP scheme and return scheme information
examples/* Multiple new examples demonstrating multi-issuer, dynamic issuer resolution, Redis caching, DPoP modes, and trusted proxy configuration
Makefile Added test-examples target and updated golangci-lint version
Comments suppressed due to low confidence (1)

jwks/provider.go:1

  • The comment mentions 'single-flight fetching' behavior, but this is implemented through the fetchMu mutex which blocks all concurrent fetches rather than allowing one to proceed while others wait for the result. This is not true single-flight behavior (like sync.Singleflight). Consider clarifying the comment to say 'serialized fetching' or 'mutex-protected fetching' to accurately describe the implementation.
package jwks

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@developerkunal developerkunal changed the base branch from master to v3 January 15, 2026 09:54
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Jan 15, 2026

Codecov Report

❌ Patch coverage is 91.05145% with 40 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.10%. Comparing base (f643eb3) to head (649ed03).
⚠️ Report is 5 commits behind head on master.

Files with missing lines Patch % Lines
jwks/multi_issuer_provider.go 86.23% 11 Missing and 8 partials ⚠️
error_handler.go 50.00% 10 Missing ⚠️
validator/validator.go 92.68% 5 Missing and 1 partial ⚠️
jwks/provider.go 93.82% 2 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #368      +/-   ##
==========================================
- Coverage   96.55%   95.10%   -1.46%     
==========================================
  Files          18       20       +2     
  Lines        1508     1878     +370     
==========================================
+ Hits         1456     1786     +330     
- Misses         34       62      +28     
- Partials       18       30      +12     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@developerkunal developerkunal force-pushed the feat/mcd-multiple-custom-domains branch from 0d24351 to 2092669 Compare January 15, 2026 13:17
@developerkunal developerkunal force-pushed the feat/mcd-multiple-custom-domains branch 2 times, most recently from 3e3e3c3 to a8a4c83 Compare January 15, 2026 15:45
   - Adjust timing assertion to accept 3-4 requests (timing variance)
   - Increase TTL from 2s to 5s to prevent cache expiry during test
   - Fixes CI failures in background refresh scenarios
@developerkunal developerkunal force-pushed the feat/mcd-multiple-custom-domains branch from a8a4c83 to e5877dd Compare January 15, 2026 20:34
Base automatically changed from v3 to master January 19, 2026 09:20
developerkunal and others added 5 commits January 21, 2026 23:45
…idation

- Merged GetWellKnownEndpointsWithIssuerValidation into GetWellKnownEndpointsFromIssuerURL
- Added expectedIssuer parameter to perform double-validation by default
- Issuer validation now mandatory (defense-in-depth for MCD security)
- Updated all callers in Provider and CachingProvider
- Updated all test mocks to include issuer field in OIDC metadata
- Simplified internal API from 2 functions to 1 function
- All tests passing (internal/oidc + jwks)
- Bump  from v0.10.3 to v0.10.5 in http-dpop-trusted-proxy, http-jwks-example, and iris-example.
- Update  from v3.0.1 to v3.0.3 in http-dpop-trusted-proxy, http-jwks-example, iris-example, and dynamic-issuer-example.
- Upgrade  from v1.6.4 to v1.6.7 in http-dpop-trusted-proxy, http-jwks-example, and iris-example.
- Upgrade  from v0.45.0 to v0.46.0 in http-dpop-trusted-proxy, http-jwks-example, and iris-example.
- Upgrade  from v0.38.0 to v0.40.0 in http-dpop-trusted-proxy, http-jwks-example, and iris-example.
- Update  from v1.6.1 to v1.7.1 in iris-example.
- Add issuer field to the OpenID configuration response in http-jwks-example.
… documentation

- Added support for Cache-Control headers in JWKS caching provider to extend cache time when allowed.
- Updated documentation to clarify security and performance features, including automatic validation of exp and nbf claims.
- Improved examples and README files to reflect new caching strategies and recommendations for multi-tenant scenarios.
…ntext

  - Refresh stale timestamp after acquiring fetch lock to prevent redundant JWKS fetches
  - Respect shorter Cache-Control max-age from IdP to support key rotation signals
  - Add io.LimitReader (1MB) on OIDC discovery response as defense-in-depth
  - Document WithCacheTTL zero-value behavior (uses default 15m)
  - Set issuer in context before resolver so it has access to unverified iss claim
  - Update test expectation to match corrected Cache-Control behavior
   Add support for symmetric (HS256/HS384/HS512) issuers in MCD scenarios
   and enforce algorithm policy before JWKS fetch to prevent algorithm
   bypass when keyFunc returns jwk.Set.

   - Replace single signatureAlgorithm with allowedAlgorithms slice
   - Add WithAlgorithms option for mixed-algorithm MCD (e.g., RS256 + HS256)
   - Enforce mutual exclusivity between WithAlgorithm and WithAlgorithms
   - Validate token alg header against allowed list before JWKS fetch (fail-fast)
   - Add IssuerKeyConfig, WithIssuerKeyConfig, and WithIssuerKeyConfigs for
     per-issuer symmetric key configuration
   - Symmetric issuers bypass OIDC discovery via static jwk.Set lookup
   - Use jws.Parse for single-pass JWS envelope parsing in ValidateToken
   - Normalize fmt.Errorf to errors.New for static error strings
   Add ProviderStats with per-issuer breakdown (OIDC vs symmetric, algorithm,
   last-used time) to help operators monitor and debug multi-tenant setups.
   Also fix ProviderCount to include symmetric issuers in the total.
@developerkunal developerkunal force-pushed the feat/mcd-multiple-custom-domains branch from 88e1e95 to e632087 Compare February 26, 2026 09:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: explicit algorithm allow/deny policy enforcement for JWKS (jwk.Set) validation

4 participants