Skip to content

[TT-17312] Gateway: client-IdP registry for JWT key & scope resolution#8268

Merged
mativm02 merged 10 commits into
masterfrom
feat/TT-17312/client-idp-registry
Jun 5, 2026
Merged

[TT-17312] Gateway: client-IdP registry for JWT key & scope resolution#8268
mativm02 merged 10 commits into
masterfrom
feat/TT-17312/client-idp-registry

Conversation

@mativm02

@mativm02 mativm02 commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an in-memory IdPRegistry (gateway/idp_registry.go) that joins an API to its client IdPs at request time, as a sibling dataset of the API definition travelling the same transports. The JWT middleware consults it only as a last fallback after the existing manual API-def branches, so DCR IdPs resolve without ever entering the API def, manual config stays authoritative, and a registry change refreshes just the registry — no router rebuild, no fleet-wide reload.

Pairs with the dashboard side (TykTechnologies/tyk-analytics#5756, the /system/clientidps feed + NoticeClientIdPChanged signal) and the MDCB GetClientIdPs RPC handler (separate ticket).

What changed

  • gateway/idp_registry.go (new) — registry with debounced (~100ms) Refresh, mode-branching fetch (direct-Dashboard HTTP /system/clientidps, RPC GetClientIdPs, file-deferred), build-then-swap rebuild with a segment-aware backstop keyed on apisByID, and scopeToPolicyMapForRequest (manual wins on collision, binding fills gaps).
  • gateway/mw_jwt.gosecretFromIdPRegistry last-fallback probe in getSecretToVerifySignature; URL-keyed fetchJWKSKey (avoids getSecretFromURL's JWTSource-gated cache miss); manual-first scope resolution in processCentralisedJWT; hasRegistryBinding routing gate in ProcessRequest.
  • ctx/ctx.go + gateway/api.goMatchedIdPBinding context key + helpers. Per-request binding lives on the request context, never the shared middleware struct (one JWTMiddleware per API, ProcessRequest runs concurrently).
  • gateway/rpc_storage_handler.go + internal/policy/{rpc.go,store_mock.gen.go}GetClientIdPs on RPCDataLoader + impl + regenerated/handwritten mocks.
  • gateway/redis_signals.goNoticeClientIdPChanged (registry-only refresh).
  • gateway/server.goidpRegistry field, init in NewGateway, doRefresh after loadGlobalApps in DoReloadWithError.

Key properties

  • Manual config is authoritative — appended as the last branch; an admin's JWTSource/JWTJwksURIs/scope map always wins. For manual APIs the probe never runs (cost zero, byte-identical behaviour).
  • iss is an unverified hint — only selects which JWKS to try; signature verification + validateIssuer still run afterward.
  • Per-binding scope_to_policy makes scope-name collisions between IdPs structurally impossible.
  • Works for both classic and OAS APIs.
  • HTTP fetch goes through executeDashboardRequestWithRecovery, inheriting nonce-failure re-registration and client config like FromDashboardService.

Testing

  • New unit/integration tests (all green, incl. -race): decoder envelope/bare-array tolerance, scope-merge collision/fill, segment backstop, build-then-swap, RPC + Dashboard fetch (incl. nonce capture), ctx roundtrip, shutdown no-op, and the three acceptance criteria — AC1 registry resolves key+scope (200), AC2 manual JWTSource wins, AC3 NoticeClientIdPChanged refreshes registry without an API reload.
  • go build ./... + go vet clean; existing TestJWT*/TestJWKS*/TestJWTScopeToPolicyMapping/TestManagementNodeRedisEvents pass (no regressions).
  • E2E framework available (dashboard-direct, portal DCR, MDCB) covering the full round-trip.

Acceptance criteria

  • AC1 — JWT API with no JWTSource/JWTJwksURIs + a registry binding returns 200; token scope maps to the bound policy.
  • AC2 — Setting JWTSource bypasses the registry; manual wins on collision, binding fills absent scopes.
  • AC3 — A registry change refreshes the registry with no "Reloading endpoints"/loadGlobalApps.

Notes / follow-ups

  • The GetClientIdPs RPC call on edge nodes requires the MDCB-side handler (parallel ticket); they ship coordinated. Against an older sink the call errors and is swallowed (previous snapshot kept).
  • Code review flagged JWKS fan-out for tokens with absent/non-matching iss (walk-all per spec). Bounded by binding count (typically 1) and on par with existing manual-JWKS refetch behaviour; negative-caching is a possible future hardening.

Ticket Details

TT-17312
Status In Code Review
Summary Gateway: client IdP registry + JWT integration

Generated at: 2026-06-05 14:28:38

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

🎯 Recommended Merge Targets

Based on JIRA ticket TT-17312: Gateway: client IdP registry + JWT integration

Fix Version: Tyk 5.14.0

⚠️ Warning: Expected release branches not found in repository

Required:

  • master - No matching release branches found. Fix will be included in future releases.

📋 Workflow

  1. Merge this PR to master first

@probelabs

probelabs Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

This PR introduces an in-memory IdPRegistry to the Gateway, enabling dynamic, request-time resolution of JWT signing keys and scope-to-policy mappings. This change decouples client Identity Provider (IdP) configurations from API definitions, allowing them to be updated centrally via a new Redis signal (NoticeClientIdPChanged) or an RPC call (GetClientIdPs) without requiring a full gateway reload. Manual configuration within an API definition remains authoritative, with the new registry serving as a fallback mechanism.

Files Changed Analysis

The changes introduce a new, self-contained IdP registry and integrate it into the gateway's core systems. The majority of the new logic is in gateway/idp_registry.go and its comprehensive test file, gateway/idp_registry_test.go, which together add over 1200 lines of code.

  • New Files: gateway/idp_registry.go and gateway/idp_registry_test.go form the core of the feature, containing the registry logic, data models, and extensive tests.
  • Modified Files:
    • gateway/mw_jwt.go: The primary consumer, updated to query the registry as a fallback for key resolution and to merge scope mappings.
    • gateway/server.go: Integrates the registry into the gateway's startup and reload lifecycle.
    • gateway/rpc_storage_handler.go, gateway/rpc_backup_handlers.go, and internal/policy/*: Add the GetClientIdPs RPC method for MDCB environments, along with backup and recovery logic.
    • gateway/redis_signals.go: Adds the NoticeClientIdPChanged signal to trigger registry-only refreshes.
    • ctx/ctx.go & gateway/api.go: Introduce a request context key to safely pass the matched IdP binding, avoiding concurrency issues in the shared middleware.

Architecture & Impact Assessment

  • What this PR accomplishes: It decouples client IdP configuration (JWKS URIs, scope mappings) from static API definitions. This allows for centralized management and dynamic updates of IdP data without service interruption, while ensuring manual API configurations always take precedence.

  • Key technical changes introduced:

    1. IdPRegistry Component: A new in-memory registry (gateway/idp_registry.go) with a reverse index (api_id -> []Binding) for fast lookups at request time.
    2. Dynamic Data Fetching: Supports fetching IdP data from the Dashboard via HTTP (/system/clientidps) or from MDCB via a new GetClientIdPs RPC call.
    3. JWT Middleware Integration: The JWT middleware (gateway/mw_jwt.go) queries the registry as a last resort, preserving backward compatibility and ensuring manual settings always win.
    4. Lightweight Hot-Reload: A new NoticeClientIdPChanged Redis signal and a dedicated idpReloadLoop for RPC trigger a debounced refresh of only the IdP registry, avoiding a full gateway reload.
    5. Thread-Safe State: Uses the http.Request context to carry per-request data (MatchedIdPBinding), preventing race conditions in the shared middleware.
  • Affected system components: Gateway Core (lifecycle), JWT Middleware, RPC Data Loader, and Redis Signal Handling.

  • Component Interaction Diagram:

graph TD
subgraph "Data Sources"
Dashboard["Dashboard via /system/clientidps"]
MDCB["MDCB via GetClientIdPs() RPC"]
end

subgraph "Gateway Node"
    IdPRegistry["IdPRegistry (in-memory)"]
    JWTMiddleware["JWT Middleware"]
    RedisHandler["Redis Signal Handler"]
    ServerLifecycle["Server Lifecycle (Startup/Reload)"]
end

Client[API Client]
Redis((Redis Pub/Sub))

ServerLifecycle --|Initializes and Refreshes|--> IdPRegistry
Dashboard --|HTTP Fetch|--> IdPRegistry
MDCB --|RPC Fetch|--> IdPRegistry

Redis --|Receives NoticeClientIdPChanged|--> RedisHandler
RedisHandler --|Triggers Refresh|--> IdPRegistry

Client --|Request with JWT|--> JWTMiddleware
JWTMiddleware --|Reads bindings as fallback|--> IdPRegistry

## Scope Discovery & Context Expansion
This PR is the gateway-side implementation of a feature that spans the Tyk stack. It creates a dependency on corresponding changes in:
- **Tyk Dashboard:** Must expose the `/system/clientidps` endpoint to provide the IdP data.
- **Tyk MDCB:** Must implement the `GetClientIdPs` RPC handler for edge-gateway deployments.

The implementation is well-isolated within the gateway. A key architectural detail is the "segment-aware backstop" in the `rebuild` function (`gateway/idp_registry.go`), which ensures that a gateway node only stores IdP data relevant to the APIs it has loaded. This is critical for performance and data segregation in segmented deployments. The use of a debounced, targeted refresh signal (`NoticeClientIdPChanged`) and a dedicated RPC poll loop (`idpReloadLoop`) improves operational efficiency by avoiding unnecessary full gateway reloads for simple IdP configuration changes.


<details>
  <summary>Metadata</summary>

  - Review Effort: 4 / 5
  - Primary Label: feature


</details>
<!-- visor:section-end id="overview" -->

<!-- visor:thread-end key="TykTechnologies/tyk#8268@4ce1012" -->

---

*Powered by [Visor](https://probelabs.com/visor) from [Probelabs](https://probelabs.com)*

*Last updated: 2026-06-05T14:30:25.733Z | Triggered by: pr_updated | Commit: 4ce1012*

💡 **TIP:** You can chat with Visor using `/visor ask <your question>`
<!-- /visor-comment-id:visor-thread-overview-TykTechnologies/tyk#8268 -->

@probelabs

probelabs Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

✅ Security Check Passed

No security issues found – changes LGTM.

✅ Architecture Check Passed

No architecture issues found – changes LGTM.

✅ Security Check Passed

No security issues found – changes LGTM.

\n\n

✅ Architecture Check Passed

No architecture issues found – changes LGTM.

\n\n

Performance Issues (1)

Severity Location Issue
🟡 Warning gateway/idp_registry.go:127-130
The `rebuild` function appends to a slice within a loop. For an API with many IdP bindings, this can lead to multiple slice reallocations and memory copies, impacting performance during registry rebuilds.
💡 SuggestionTo optimize memory allocation, consider a two-pass approach. The first pass would count the number of bindings for each `apiID`, allowing for pre-allocation of slices with the exact capacity needed. The second pass would then populate these pre-allocated slices. This would be more efficient in deployments with APIs mapped to a large number of identity providers.

Quality Issues (2)

Severity Location Issue
🟡 Warning gateway/rpc_storage_handler.go:864
The function `CheckForIdPReload` uses a hardcoded `time.Sleep(1 * time.Second)` for backoff when an RPC call fails. Using magic numbers for timing intervals reduces code clarity and maintainability.
💡 SuggestionReplace the hardcoded value with a named constant to improve readability and make it easier to modify. For example, define `const idpReloadErrorBackoff = 1 * time.Second` and use this constant in the `time.Sleep` call.
🟡 Warning gateway/idp_registry_test.go:820-827
The test `TestIdPRegistry_Refresh_DebouncedRebuild` uses a polling loop with `time.Sleep` to wait for an asynchronous, time-based operation (a debounced refresh) to complete. This can cause test flakiness, especially in CI environments where execution speed can vary.
💡 SuggestionFor more robust testing of asynchronous operations, consider using synchronization primitives like channels or callbacks. If refactoring the component under test is an option, dependency injection for the timer would allow for deterministic testing using a mock clock.

Powered by Visor from Probelabs

Last updated: 2026-06-05T14:30:14.584Z | Triggered by: pr_updated | Commit: 4ce1012

💡 TIP: You can chat with Visor using /visor ask <your question>

@mativm02
mativm02 force-pushed the feat/TT-17312/client-idp-registry branch 2 times, most recently from 7599216 to 7df65f9 Compare June 2, 2026 12:48
@mativm02

mativm02 commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

Validated end-to-end against the e2e framework (T1)

Built the gateway from this branch + the dashboard from tyk-analytics #5756 (TT-17310) and ran the dashboard Client IdP e2e (real Keycloak DCR, no portal). Both classic and OAS JWT APIs — each with empty jwt_source and empty jwt_scope_to_policy_mapping — resolved their signing key from the registry's Keycloak JWKS and mapped token scopes to the bound policies:

classic R1 (quota 1/4, rate 1/2) → 200
classic R2 (quota 2/4, rate 2/2) → 200
classic R3 (rate exceeded)       → 429
OAS     R1 (quota 3/4, rate 1/2) → 200
OAS     R2 (quota 4/4, rate 2/2) → 200
OAS     R3 (rate exceeded)       → 429
classic R4 (shared quota 4/4)    → 403
=> DASHBOARD CLIENT IDP TEST PASSED

This exercises the full chain (registry refresh via /system/clientidpssecretFromIdPRegistry → JWKS fetch → scope→policy via the binding → product ACL + plan rate/quota partitions) for both API formats.

Bug caught & fixed by the e2e

The OAS run initially crashed the gateway with a nil-pointer panic in getScopeClaimNameOAS (mw_jwt.go): a registry-only OAS API has no JWT block in its OAS definition, so OAS.GetJWTConfiguration() (and its .Scopes) is nil. My unit AC1 used a classic API and missed it. Fixed with a nil guard (falls back to the default scope claim) and added a regression test TestGetScopeClaimNameOAS_NilJWTConfig. Amended into the branch.

@mativm02
mativm02 force-pushed the feat/TT-17312/client-idp-registry branch 2 times, most recently from 108999e to a247b17 Compare June 2, 2026 13:41
@mativm02

mativm02 commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

Addressed Visor findings (all Visor checks now green)

Folded into the branch:

  • Security (SSRF, fetchJWKSKey)jwks_uri is admin-controlled config (Dashboard/MDCB), the same trust domain + ExternalHTTPClientFactory as the existing JWTSource/JWTJwksURIs fetches, so it inherits their SSRF posture rather than adding a new one. Added an explicit http(s)-scheme guard (rejects file:///gopher:///… vectors) and documented the boundary. Host/IP egress blocking is intentionally not applied — DCR IdPs (e.g. internal Keycloak) legitimately live on private networks.
  • Security (log hygiene, fetchFromDashboard) — no longer logs the raw 403 response body; returns a static 403 Forbidden error.
  • Performance (thundering herd, fetchJWKSKey) — concurrent JWKS fetches for the same (API, URL) are now coalesced via singleflight (golang.org/x/sync — already a direct dep), so a burst on a cold/expired cache triggers one network fetch.
  • Performance (memory, fetchFromDashboard) — stream-decode the feed with json.NewDecoder instead of buffering the whole body via io.ReadAll.

New tests: TestFetchJWKSKey_RejectsNonHTTPScheme; strengthened the 403 test to assert the body isn't leaked. Existing JWT/JWKS suite + the T1 e2e key-resolution path (AC1/AC2) still pass.

Note: the s1_scanner (SentinelOne CNS) failure is the repo-wide dependency-vulnerability scan — this PR changes no go.mod/go.sum/vendor, so it's pre-existing on master and not addressable here.

Comment thread gateway/idp_registry.go Outdated
@mativm02
mativm02 force-pushed the feat/TT-17312/client-idp-registry branch 4 times, most recently from 499c9da to 2d9dc70 Compare June 3, 2026 14:52
…lution

Add an in-memory IdPRegistry (gateway/idp_registry.go) that joins APIs to
their client IdPs at request time, as a sibling dataset of the API definition
travelling the same transports. The JWT middleware probes it only as a last
fallback after the existing manual API-def branches, so manual config
(JWTSource/JWTJwksURIs/scope map) stays authoritative and byte-identical.

- Registry refresh mirrors the API-def load path: HTTP /system/clientidps for
  direct-Dashboard (via executeDashboardRequestWithRecovery), RPC GetClientIdPs
  for MDCB/edge, file mode deferred. Debounced ~100ms; build-then-swap rebuild
  with a segment-aware backstop keyed on apisByID.
- JWT path: secretFromIdPRegistry resolves the signing key via a URL-keyed JWKS
  helper (iss is an unverified hint only; JWKS fetches coalesced via singleflight
  to avoid thundering herd); per-request matched binding travels on the request
  context (ctx.MatchedIdPBinding), never the shared middleware struct.
  Manual-first/binding-fill scope resolution in processCentralisedJWT.
- Direct-Dashboard: NoticeClientIdPChanged Redis signal refreshes only the
  registry (no router rebuild, no APISpec reload). Payload is the
  {org_id, client_idp_id} JSON contract.
- MDCB/edge: a parallel idpReloadLoop() long-polls the dedicated CheckIdPReload
  RPC and refreshes ONLY the registry (one GetClientIdPs RPC) — no full
  API/policy resync. GetClientIdPs + CheckIdPReload are registered in
  dispatcherFuncs so the gorpc client can call them.
- Registry also refreshes on every DoReload.

Works for classic and OAS APIs.
@mativm02
mativm02 force-pushed the feat/TT-17312/client-idp-registry branch from 2d9dc70 to e76b0a9 Compare June 3, 2026 16:55
tbuchaillot and others added 3 commits June 4, 2026 11:06
…loadLoop

SonarCloud: extract unverifiedIssuerHint + keyForBinding helpers to bring
secretFromIdPRegistry cognitive complexity under the limit (S3776); inline the
idpReloadLoop reload check (S8193).
Add unit coverage for the registry/JWT helpers and the RPC methods to clear the
SonarCloud 80% new-code coverage gate:
- unverifiedIssuerHint, keyForBinding, keyFromJWKSet, cachedJWKSet,
  secretFromIdPRegistry early returns, fetchJWKSKey cache-hit + fetch-error,
  unmarshalIdPs invalid input, fetchFromDashboard non-200.
- Registry: Refresh debounce rebuild, fetchFromRPC default loader, doRefresh.
- RPC: GetClientIdPs + CheckForIdPReload RPC-down error paths.

@MFCaballero MFCaballero left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚀

Mirror the API/policy RPC backup so an edge gateway keeps resolving registry-only
JWT APIs when MDCB is unreachable:
- saveRPCIdPsBackup / LoadIdPsFromRPCBackup store the raw GetClientIdPs payload
  under node-clientidp-backup:<tags> (encrypted), guarded against a nil storage
  handler.
- fetchFromRPC loads from backup in emergency mode and refreshes the backup on a
  successful sync.

Tests: save/load round-trip, malformed-payload rejection, and the emergency-mode
fetch path.

@sredxny sredxny left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

left some comments, we can discuss it

Comment thread gateway/api.go Outdated
Comment thread gateway/idp_registry.go Outdated
Comment thread gateway/rpc_backup_handlers.go Outdated
- Drop the brittle api_loader.go:403 line reference from the ctxSetMatchedBinding
  comment.
- fetchFromDashboard uses http.NewRequestWithContext(gw.ctx, ...) so the request
  is cancelled on gateway shutdown.
- Remove the no-op compress/decompress wrappers for the IdP backup; the small
  payload is stored raw (encrypted only).
@mativm02
mativm02 requested a review from sredxny June 5, 2026 11:47
- Inline two if-condition variable declarations in tests (idp_registry_test,
  rpc_test).
- ST1005: drop trailing period and lowercase the client-IdP backup error
  strings in rpc_backup_handlers.
tbuchaillot and others added 2 commits June 5, 2026 15:36
MDCB's CheckIdPReload returns the list of changed client_idp_id values, not a
bool. Decode []string and use it as a 'something changed' signal: on a non-empty
result, refresh the whole registry once via doRefresh (one GetClientIdPs RPC),
which also handles deletes. Keeps the gateway's bulk-refresh design while
matching the tyk-sink RPC signature.
@mativm02
mativm02 enabled auto-merge (squash) June 5, 2026 14:23
errcheck (check-type-assertions) flagged the discarded ok in
changed.([]string) as a reliability bug; check it explicitly.
@sonarqubecloud

sonarqubecloud Bot commented Jun 5, 2026

Copy link
Copy Markdown

Quality Gate Passed Quality Gate passed

Issues
0 New issues
0 Accepted issues

Measures
0 Security Hotspots
88.9% Coverage on New Code
1.0% Duplication on New Code

See analysis details on SonarQube Cloud

@mativm02
mativm02 merged commit 7b01474 into master Jun 5, 2026
54 of 55 checks passed
@mativm02
mativm02 deleted the feat/TT-17312/client-idp-registry branch June 5, 2026 15:50
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.

4 participants