Skip to content

[TT-17174] OAuth2 scope check (per-operation / per-MCP-primitive)#8203

Merged
lghiur merged 4 commits into
masterfrom
TT-17174-scope-check-per-operation
Jun 3, 2026
Merged

[TT-17174] OAuth2 scope check (per-operation / per-MCP-primitive)#8203
lghiur merged 4 commits into
masterfrom
TT-17174-scope-check-per-operation

Conversation

@lghiur

@lghiur lghiur commented May 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

Extends the new oauth2 security scheme's scope check (TT-17173 added the API-level baseline) to read per-operation OAS security: declarations and per-MCP-primitive security arrays. Operators declare required scopes once in the OAS document; the gateway enforces them per request. Grammar is OR-of-AND (security: is OR across alternatives, AND across the scopes within an alternative). scopeSource composes the two paths: union (default) requires both, operation ignores the global baseline, global ignores per-op. On a missing scope the 403 cites the first declared alternative in the RFC 6750 WWW-Authenticate: Bearer error="insufficient_scope" scope="…" challenge; MCP/JSON-RPC routes get the failure wrapped in a JSON-RPC 2.0 error envelope. An operation guarded by another scheme (e.g. JWT) contributes an empty AND-group, so the oauth2 middleware stands down for that route — preserving coexistence. The whole check is gated on oauth2.scopeCheck.enabled (default off), so existing APIs are unaffected.

apidef: MCPPrimitive.Security (openapi3.SecurityRequirements), DeriveOAuth2Scopes / SortedOAuth2Scopes (walk root + per-op + per-MCP-primitive security:), mcpTools[].security schema delta. gateway: requiredScopeAlternativesForRequest (per-op + global composition per scopeSource, union as cross-product via unionScopeAlternatives), perOperationScopeAlternatives (MCP primitive via JSONRPC routing state + REST op via OAS path lookup), appendOAuth2Alternatives, oauth2ScopeSource, dedupPreserveOrder. Unit + StartTest end-to-end tests.

Story

Companion PRs

This story spans three repos and ships together:

Reviewers: please block until all three are green.

Stacked on

Base is TT-17173-scope-check-api-level (Story 02 / TT-17173). Review/merge TT-17173 first, then this can be retargeted to master.

Test plan

  • go test ./apidef/oas/ -run 'OAuth2|MCPPrimitive' and go test ./gateway/ -run OAuth2 — green locally
  • Python e2e (in the tyk-analytics PR): pytest tests/api/tests/mcp/mcp_oauth2_per_op_scope_check_test.py — 15/15 against a live dashboard + gateway + Keycloak
  • newman run on the Story-03 Postman collection — 17/17 against the live stack
  • Playwright lifecycle (in the tyk-analytics PR), --repeat-each=5 --workers=1 — 5/5 chromium + 5/5 firefox
  • Manual walk of the manual-test guide

Note: pre-existing unrelated failure apidef/oas TestAPIContext_getValidationOptionsFromConfig panics on the parent branch too.

Risk

Schema delta is confined to apidef/mcp/schema/x-tyk-api-gateway.json (mcpTools[].security) — per-op REST security: is native OpenAPI, read at runtime with no x-tyk-extension delta. The map-probe disambiguator and the new helpers extend Story 02's structures without changing existing paths. Coexistence is covered by tests (an op secured by another scheme is not blocked by oauth2). Default-off gating means a no-op for any API that doesn't opt into scopeCheck.

🤖 Generated with Claude Code

Ticket Details

TT-17174
Status Merge
Summary OAuth2 scope check — per-operation and per-MCP-primitive

Generated at: 2026-06-03 11:30:32

@probelabs

probelabs Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

This pull request introduces a significant enhancement to the OAuth2 security scheme, enabling per-operation and per-MCP-primitive scope checking. This allows API definitions to specify granular authorization requirements for each endpoint directly within the OpenAPI (OAS) document, moving away from a single, API-wide scope policy.

The core of the change is the ability to read and enforce scopes from the standard OAS security array on each operation, as well as a new security field on Tyk's MCP primitives (tools, resources, prompts). The enforcement logic is governed by a scopeSource parameter, which can be set to global (only API-level scopes), operation (only endpoint-level scopes), or union (a combination of both, default). The entire feature is gated by a top-level oauth2.scopeCheck.enabled flag, ensuring backward compatibility for existing APIs.

To support this, the API definition has been extended, and the gateway's OAuth2 middleware has been substantially updated to dynamically resolve and enforce these fine-grained security requirements on a per-request basis. The implementation also correctly handles coexistence with other security schemes; an operation secured by a non-OAuth2 scheme (like JWT) will not be blocked by this new middleware.

Files Changed Analysis

The changes are concentrated in the apidef and gateway packages, with significant additions to testing:

  • apidef/: The API definition schemas (apidef/mcp/schema/, apidef/oas/schema/, apidef/streams/schema/) and their corresponding Go types (apidef/oas/mcp_primitive.go, apidef/oas/operation.go, apidef/oas/oauth2.go) have been updated. Key additions include the Security field to MCPPrimitive and the ScopeCheck struct to Operation. New helpers like DeriveOAuth2Scopes have been added to collect all defined scopes from an API definition.
  • gateway/: The primary logic resides in gateway/mw_oauth2.go (+269 lines), which now contains the complex logic for resolving scope requirements based on the scopeSource. A critical fix was made in gateway/model_apispec.go to use findOASRoute, which leverages the gateway's router to correctly match requests to operations with path templates (e.g., /users/{id}), preventing a potential security bypass.
  • Tests: Testing has been massively expanded, especially in gateway/mw_oauth2_test.go (+549 lines). New unit and end-to-end tests cover all scopeSource modes, the OR-of-AND logic, coexistence with other auth schemes, and crucially, the correct enforcement for templated paths.

Architecture & Impact Assessment

  • What this PR accomplishes: It transitions OAuth2 scope enforcement from a monolithic, API-wide policy to a flexible, per-endpoint authorization model that aligns with OpenAPI standards.

  • Key technical changes introduced:

    1. Dynamic Scope Resolution: The OAuth2 middleware now resolves required scopes for each request by inspecting the matched OAS operation or MCP primitive.
    2. Router-Based Operation Matching: The system now uses the gateway's main OAS router (findOASRoute) to match requests to operations. This is a security-critical change that correctly handles path parameters, fixing a potential authorization bypass vulnerability present in simpler path-matching approaches.
    3. Configurable Scope Composition: The scopeSource setting (union, operation, global) provides operators with explicit control over how global and local security requirements are combined.
  • Affected system components: The primary impact is on the Tyk Gateway's OAuth2 middleware and the API Definition processing. The full feature depends on companion PRs in tyk-analytics and tyk-analytics-ui for UI configuration.

Authorization Flow Diagram

graph TD
    A[Request Arrives] --> B{OAuth2 Scope Check Enabled?};
    B -- No --> C[Skip Scope Check];
    B -- Yes --> D[Find OAS Operation/Primitive via Router];
    D -- Not Found --> C;
    D -- Found --> E[Resolve Required Scopes based on scopeSource];
    E --> F{Are scopes required?};
    F --|"No (e.g., other auth scheme)"|--> C;
    F -- Yes --> G[Extract Scopes from Token];
    G --> H{Token satisfies any scope alternative?};
    H -- Yes --> I[Allow Request];
    H -- No --> J["Deny Request (403 Insufficient Scope)"];
Loading

Scope Discovery & Context Expansion

  • This PR is part of a larger, multi-repository feature. The full functionality, including the ability to configure these settings in the UI, requires the companion PRs in tyk-analytics and tyk-analytics-ui.
  • The change to use findOASRoute is a crucial security hardening measure. Previous implementations that might use simple string matching on paths would fail to secure endpoints with path variables (e.g., /users/{userId}). The new end-to-end tests in gateway/mw_oauth2_test.go for templated paths (/op8/accounts/42) explicitly confirm this vulnerability has been addressed.
  • The introduction of the union mode for scopeSource creates a powerful but potentially complex authorization model by computing a cross-product of global and per-operation scopes. This could have performance implications for APIs with a very large number of scope alternatives, which may require future optimization or careful schema design.
Metadata
  • Review Effort: 4 / 5
  • Primary Label: feature

Powered by Visor from Probelabs

Last updated: 2026-06-03T11:32:22.927Z | Triggered by: pr_updated | Commit: 5546404

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

@probelabs

probelabs Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

Security Issues (1)

Severity Location Issue
🟡 Warning gateway/mw_oauth2.go:234-242
The `unionScopeAlternatives` function calculates the Cartesian product of per-operation and global scope alternatives. When the `scopeSource` is "union", a large number of alternatives at both the global and per-operation levels can lead to excessive memory allocation and CPU usage for each request, creating a potential denial-of-service vulnerability.
💡 SuggestionIntroduce a configurable limit for the maximum number of combined scope alternatives. If the product of `len(perOp) * len(global)` exceeds this limit, log an error and deny the request to prevent excessive resource consumption. This would mitigate the risk of a misconfigured API definition causing gateway instability.

✅ Architecture Check Passed

No architecture issues found – changes LGTM.

Performance Issues (1)

Severity Location Issue
🟡 Warning gateway/mw_oauth2.go:230-247
The `unionScopeAlternatives` function calculates the cross-product of per-operation and global scope alternatives, which has a time complexity of O(N*M) where N is the number of per-operation alternatives and M is the number of global alternatives. Inside the nested loops, new slices are allocated for merging, and `dedupePreserveOrder` is called, which also performs allocations. For APIs with a large number of alternatives at both levels, this function could become a performance bottleneck on the request hot path due to increased CPU usage and memory allocations.
💡 SuggestionThe current implementation is correct and clear, but its performance characteristics should be noted. Consider adding a code comment highlighting the O(N*M) complexity. For users of this feature, it should be documented that when using the "union" `scopeSource`, the number of global and per-operation security alternatives should be kept reasonably low to avoid performance degradation. No immediate code change is required, but this area should be considered for optimization if profiling reveals it to be a bottleneck.

Quality Issues (3)

Severity Location Issue
🔴 Critical gateway/mw_oauth2.go:233-254
The `findOASOperation` function uses a direct string-based map lookup to match the request path to an OAS operation. This implementation does not support OAS paths with variables (e.g., `/users/{userId}`). As a result, for any endpoint defined with path parameters, the function will fail to find the operation, and the per-operation scope check will be silently bypassed. When `scopeSource` is set to "operation", this constitutes a complete authorization bypass for those endpoints.
💡 SuggestionThe middleware should not re-implement routing logic. It should leverage the gateway's main router to identify the matched operation's path template. The main router (e.g., `gorilla/mux`) typically stores the matched route information, including the path template, in the request context. This path template should be retrieved from the context and used to look up the `PathItem` in the OAS definition.
🟠 Error gateway/mw_oauth2_test.go:418-593
The new unit and end-to-end tests for per-operation scope checking do not include any test cases for endpoints with path parameters (e.g., `/users/{id}`). All tested paths are static strings. This testing gap allowed a critical authorization bypass vulnerability in `findOASOperation` to be missed.
💡 SuggestionAdd test cases to both `TestOAuth2_RequiredScopeAlternatives_RESTOperation` and `TestOAuth2Middleware_PerOperationScopeCheck_EndToEnd` that define and call endpoints with path parameters. These tests should verify that per-operation scopes are correctly enforced for such endpoints. For example, add a path like `/users/{id}` to the test OAS definitions and make requests to `/users/123`.
🟡 Warning gateway/mw_oauth2.go:109-144
The logic to resolve the required scope alternatives for a request is executed on every request. This involves path lookups, and for `scopeSource: "union"`, computing a cross-product of scope alternatives. Since these security requirements are static for a given API definition, this computation can be performed once when the API is loaded.
💡 SuggestionConsider pre-calculating and caching the resolved scope requirements for each operation and MCP primitive when the API definition is loaded. The middleware could then perform a faster lookup from this cache on each request, reducing latency. This would be particularly beneficial for the "union" mode.

Powered by Visor from Probelabs

Last updated: 2026-06-03T11:31:57.802Z | Triggered by: pr_updated | Commit: 5546404

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

@lghiur
lghiur force-pushed the TT-17173-scope-check-api-level branch from 59534f8 to 4bccd2b Compare May 13, 2026 12:03
@lghiur
lghiur force-pushed the TT-17174-scope-check-per-operation branch from 897b39b to 39e200e Compare May 13, 2026 12:04
@lghiur
lghiur force-pushed the TT-17173-scope-check-api-level branch from 4bccd2b to 78cca80 Compare May 13, 2026 14:24
@lghiur
lghiur force-pushed the TT-17174-scope-check-per-operation branch from 39e200e to 840f356 Compare May 13, 2026 14:29
@lghiur
lghiur force-pushed the TT-17173-scope-check-api-level branch from 78cca80 to f3ba544 Compare May 14, 2026 08:26
@lghiur
lghiur force-pushed the TT-17174-scope-check-per-operation branch from 840f356 to f79b58e Compare May 14, 2026 08:27
@lghiur
lghiur force-pushed the TT-17173-scope-check-api-level branch from f3ba544 to f43707f Compare May 15, 2026 06:52
@lghiur
lghiur force-pushed the TT-17174-scope-check-per-operation branch from f79b58e to f3bdd76 Compare May 15, 2026 07:01
@lghiur
lghiur force-pushed the TT-17173-scope-check-api-level branch from f43707f to da17d5a Compare May 15, 2026 07:03
@lghiur
lghiur force-pushed the TT-17174-scope-check-per-operation branch from f3bdd76 to 3453698 Compare May 15, 2026 07:04
@lghiur
lghiur force-pushed the TT-17173-scope-check-api-level branch 8 times, most recently from 4961392 to f0154dd Compare May 18, 2026 14:19
@lghiur
lghiur force-pushed the TT-17174-scope-check-per-operation branch from 3453698 to 09a4f6b Compare May 18, 2026 16:16
@lghiur
lghiur force-pushed the TT-17173-scope-check-api-level branch from f0154dd to f624baf Compare May 18, 2026 18:37
@lghiur
lghiur force-pushed the TT-17174-scope-check-per-operation branch from 09a4f6b to 6a20e30 Compare May 19, 2026 05:44
@lghiur
lghiur force-pushed the TT-17173-scope-check-api-level branch from f624baf to 0a3f94b Compare May 19, 2026 13:15
@lghiur
lghiur force-pushed the TT-17174-scope-check-per-operation branch 3 times, most recently from dbce1e2 to 708c30d Compare May 19, 2026 14:26
@lghiur
lghiur force-pushed the TT-17173-scope-check-api-level branch from 0a3f94b to 5042b9a Compare May 19, 2026 16:41
@lghiur
lghiur force-pushed the TT-17174-scope-check-per-operation branch from 708c30d to f82313a Compare May 19, 2026 16:42
@lghiur
lghiur force-pushed the TT-17174-scope-check-per-operation branch from f82313a to b60992c Compare May 20, 2026 12:50
@lghiur

lghiur commented May 20, 2026

Copy link
Copy Markdown
Collaborator Author

/review this comment The `findOASOperation` function uses a direct string-based map lookup to match the request path to an OAS operation. This implementation does not support OAS paths with variables (e.g., `/users/{userId}`). As a result, for any endpoint defined with path parameters, the function will fail to find the operation, and the per-operation scope check will be silently bypassed. When `scopeSource` is set to "operation", this co has been fixed in this PR, not sure why the comment appears. findOASRoute is used, which matches via the OAS router (oasRouter.FindRoute), the same template-aware router OAS request validation uses

@probelabs

probelabs Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

Powered by Visor from Probelabs

Last updated: 2026-05-20T14:16:43.537Z | Triggered by: lghiur | Commit: a1c3b37

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

@probelabs

probelabs Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor
\n\n \n\n \n\n

Powered by Visor from Probelabs

Last updated: 2026-05-20T14:16:44.611Z | Triggered by: lghiur | Commit: a1c3b37

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

@lghiur

lghiur commented May 20, 2026

Copy link
Copy Markdown
Collaborator Author

/visor ask this comment The findOASOperation function uses a direct string-based map lookup to match the request path to an OAS operation. This implementation does not support OAS paths with variables (e.g., /users/{userId}). As a result, for any endpoint defined with path parameters, the function will fail to find the operation, and the per-operation scope check will be silently bypassed. When scopeSource is set to "operation", this co has been fixed in this PR, not sure why the comment appears. findOASRoute is used, which matches via the OAS router (oasRouter.FindRoute), the same template-aware router OAS request validation uses

@probelabs

probelabs Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

Powered by Visor from Probelabs

Last updated: 2026-05-20T14:18:58.391Z | Triggered by: lghiur | Commit: a1c3b37

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

Comment thread gateway/mw_oauth2.go Outdated
Base automatically changed from TT-17173-scope-check-api-level to master May 27, 2026 10:44
lghiur and others added 2 commits May 27, 2026 21:06
… MCP primitive

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
MCP tool, resource and prompt names share no namespace. Looking the
primitive up by name across all three maps let a same-named primitive
of another type fold its `security:` in as an extra OR-alternative —
a resource-only token could then authorize a tools/call. Select the
primitive map by the routing-state PrimitiveType.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@lghiur
lghiur force-pushed the TT-17174-scope-check-per-operation branch from 7b49eee to 1d0d8ad Compare May 27, 2026 18:06
Comment thread gateway/mw_oauth2.go
case len(global) == 0:
return perOp
}
out := make([][]string, 0, len(perOp)*len(global))
Comment thread gateway/mw_oauth2.go
out := make([][]string, 0, len(perOp)*len(global))
for _, p := range perOp {
for _, g := range global {
merged := make([]string, 0, len(p)+len(g))
@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

🎯 Recommended Merge Targets

Based on JIRA ticket TT-17174: OAuth2 scope check — per-operation and per-MCP-primitive

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

@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

🚨 Jira Linter Failed

Commit: 5546404
Failed at: 2026-06-03 11:30:34 UTC

The Jira linter failed to validate your PR. Please check the error details below:

🔍 Click to view error details
failed to validate Jira issue: jira ticket TT-17174 has status 'Merge' but must be one of: In Code Review, Ready For Dev, Dod Check, In Dev

Next Steps

  • Ensure your branch name contains a valid Jira ticket ID (e.g., ABC-123)
  • Verify your PR title matches the branch's Jira ticket ID
  • Check that the Jira ticket exists and is accessible

This comment will be automatically deleted once the linter passes.

@sonarqubecloud

sonarqubecloud Bot commented Jun 3, 2026

Copy link
Copy Markdown

Quality Gate Passed Quality Gate passed

Issues
0 New issues
0 Accepted issues

Measures
0 Security Hotspots
89.6% Coverage on New Code
0.0% Duplication on New Code

See analysis details on SonarQube Cloud

@lghiur
lghiur merged commit 749796e into master Jun 3, 2026
86 of 90 checks passed
@lghiur
lghiur deleted the TT-17174-scope-check-per-operation branch June 3, 2026 15:26
lghiur added a commit that referenced this pull request Jun 9, 2026
…oauth2 (gateway) (#8209)

Story 04 of the `oauth2` security-scheme series (stacked on #8203 /
TT-17174). Spec: `token-exchange/JIRA_ISSUES/04-prm-new-structure/`.

Moves RFC 9728 Protected Resource Metadata into the `oauth2` scheme:
`authentication.securitySchemes[<name>].oauth2.protectedResourceMetadata`.
The deprecated top-level `authentication.protectedResourceMetadata`
block keeps working unchanged; when both are configured the new one is
the sole authority (Story 05 handles migration).

**apidef/oas**
- `OAuth2PRM` struct (`enabled`, `wellKnownPath`, `resource`,
`authorizationServers`, `scopesSupported`, `autoDeriveScopes`) +
`GetWellKnownPath` / `IsAutoDeriveScopes`; `OAuth2.HasContent` and the
oauth2 sub-block map-probe recognise the `protectedResourceMetadata`
key.
- `OAS.OAuth2PRMScopesSupported`: the hybrid `scopes_supported` — union
of operator-supplied `scopesSupported`, the scheme's `scopeCheck.scopes`
baseline (flattened across alternatives), and (by default) the
auto-derived per-operation / per-MCP-primitive `security:` scopes.
- `x-tyk-api-gateway(.strict).json`: `X-Tyk-OAuth2-PRM` sub-schema.

**gateway**
- `APISpec.GetOAuth2PRMConfig` accessor.
- `PRMMiddleware` serves the new block (static assembly) when
configured, short-circuiting the old block; always advertises
`bearer_methods_supported: ["header"]` (RFC 9728 §2).
- `prmMetadataURL` resolves the canonical PRM URL (new wins over old);
the scope-check middleware appends a `resource_metadata=` parameter (RFC
9728 §5.1) to its `insufficient_scope` / `invalid_token` Bearer
challenges when the API publishes a PRM document.

**Tests**: `apidef/oas/oauth2_prm_test.go` (struct + scopes-union
matrix); `gateway/mw_protected_resource_oauth2_test.go` (well-known
serving, custom path, `autoDeriveScopes:false`, new-wins-over-old,
`resource_metadata` on 403/401, PRM-disabled omits it).

🤖 Generated with [Claude Code](https://claude.com/claude-code)













<!---TykTechnologies/jira-linter starts here-->

### Ticket Details

<details>
<summary>
<a href="https://tyktech.atlassian.net/browse/TT-17175" title="TT-17175"
target="_blank">TT-17175</a>
</summary>

|         |    |
|---------|----|
| Status  | Ready for Testing |
| Summary | Protected Resource Metadata — new structure inside `oauth2`
|

Generated at: 2026-05-27 18:07:40

</details>

<!---TykTechnologies/jira-linter ends here-->

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
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.

3 participants