[TT-17173] OAuth2 scope check (API-level / global)#8199
Conversation
|
This PR introduces an OAS-native OAuth 2.0 scope-checking middleware to the Tyk Gateway. This feature enables API-level or global scope enforcement based on JWT claims. The required scopes are defined in the standard OAS Files Changed AnalysisThis PR adds a significant new feature, reflected in 17 changed files with 2372 additions and 63 deletions. The core logic is encapsulated in new files:
Configuration and data structure changes are primarily in:
The new middleware is wired into the request processing chain in Architecture & Impact Assessment
Request Flow DiagramsequenceDiagram
participant Client
participant Tyk Gateway
participant "Auth Middleware (e.g., JWT)"
participant "OAuth2Middleware (New)"
participant Upstream
Client->>+Tyk Gateway: Request with Bearer Token
Tyk Gateway->>+"Auth Middleware (e.g., JWT)": Verify Token Signature
"Auth Middleware (e.g., JWT)"-->>-Tyk Gateway: Authentication OK
Tyk Gateway->>+"OAuth2Middleware (New)": Check Scopes
"OAuth2Middleware (New)"->>"OAuth2Middleware (New)": Parse claims from token (unverified)
"OAuth2Middleware (New)"->>"OAuth2Middleware (New)": Compare token scopes to required scopes
alt Scopes Sufficient
"OAuth2Middleware (New)"-->>-Tyk Gateway: Authorization OK
Tyk Gateway->>+Upstream: Forward Request
Upstream-->>-Tyk Gateway: Response
Tyk Gateway-->>-Client: Response
else Scopes Insufficient
"OAuth2Middleware (New)"-->>-Tyk Gateway: 403 Forbidden
Tyk Gateway-->>-Client: 403 with WWW-Authenticate header
end
Scope Discovery & Context Expansion
References
Metadata
Powered by Visor from Probelabs Last updated: 2026-05-26T15:22:31.171Z | Triggered by: pr_updated | Commit: af52069 💡 TIP: You can chat with Visor using |
Security Issues (1)
Architecture Issues (2)
Security Issues (1)
Quality Issues (2)
Powered by Visor from Probelabs Last updated: 2026-05-26T15:22:03.732Z | Triggered by: pr_updated | Commit: af52069 💡 TIP: You can chat with Visor using |
|
/review |
6f4e929 to
a0f59d5
Compare
59534f8 to
4bccd2b
Compare
baf9ceb to
7f92ebd
Compare
4bccd2b to
78cca80
Compare
da95b9b to
f126d68
Compare
78cca80 to
f3ba544
Compare
## Summary Introduces the new-style `oauth2` security scheme as an empty container. Operators can declare and round-trip the scheme; the sub-blocks (scope check, protected-resource metadata, token exchange, introspection) plug into this container in follow-up work. Apidef: `OAuth2` struct (master `Enabled` + standard `AuthSources`), `HasContent` / `IsEmpty`, `fillOAuth2` / `fillOAuth2OASScheme`, `GetTykOAuth2Config` / `IsOAuth2Scheme` helpers, schema deltas in 4 files (oas, oas-strict, mcp, streams). Gateway: `APISpec.GetOAuth2Config` returns one configured oauth2 scheme on an OAS API. Duplicate `oauth2` schemes on the same API are not validated — `GetOAuth2Config` returns the first match from map iteration. This matches the behaviour of every other auth scheme on the `SecuritySchemes` map (JWT, Token, HMAC, Basic, OIDC, ExternalOAuth); there is no per-scheme uniqueness validator anywhere in `apidef/oas` for those schemes either. ## Story - JIRA: https://tyktech.atlassian.net/browse/TT-17172 ## Companion PRs GW-only — this work ships to `tyk` only. Sub-blocks land in TT-17173 (#8199, stacked on this branch, with companions TykTechnologies/tyk-analytics#5629 and TykTechnologies/tyk-analytics-ui#4370) and beyond. ## Test plan - [x] `go test ./apidef/oas/ ./gateway/` clean — `HasContent` / `IsEmpty` / fill round-trip / `IsOAuth2Scheme` / `TestGetOAuth2Config_AcceptsDuplicatesAndReturnsOne` (pins that duplicate oauth2 schemes are accepted at load time and `GetOAuth2Config` returns one) - [ ] Manual smoke ## Risk Schema deltas in 4 files (oas, oas-strict, mcp, streams) — they mirror each other and must stay in sync; drift is a silent-UI-breakage class of bug. The map-probe disambiguator is intentionally absent at this stage and is introduced when the first sub-block lands. 🤖 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-17172" title="TT-17172" target="_blank">TT-17172</a> </summary> | | | |---------|----| | Status | In Code Review | | Summary | Introduce new `oauth2` security scheme (foundation) | Generated at: 2026-05-14 08:07:27 </details> <!---TykTechnologies/jira-linter ends here--> Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
f43707f to
da17d5a
Compare
54cb29a to
5bc606b
Compare
| // not own and would duplicate the upstream verifier. | ||
| func ParseUnverifiedClaims(token string) (jwt.MapClaims, error) { | ||
| parser := jwt.NewParser(jwt.WithoutClaimsValidation()) | ||
| parsed, _, err := parser.ParseUnverified(token, jwt.MapClaims{}) |
f624baf to
0a3f94b
Compare
| @@ -0,0 +1,8 @@ | |||
| // Package oauth2common holds the pure-function helpers used by the | |||
There was a problem hiding this comment.
I believe this file has no real use.
Adds the OAS-native OAuth 2.0 security scheme with global scope-check
enforcement wired into the gateway middleware chain. Required scopes
live in the OAS root `security:` array (standard OpenAPI Security
Requirement Object); the Tyk-extension scheme carries only the
token-side knobs (where to read scopes from the JWT, which claims to
merge, how to split string-form claims, which source modes drive
enforcement).
apidef
- New apidef.APIDefinition.SecurityRequirementScopes (sibling of
SecurityRequirements) preserves per-scheme scope arrays through the
OAS extract/fill cycle. Each entry in the slice is a
`map[scheme][]scope` aligned by index with SecurityRequirements.
- Both SecurityRequirements and SecurityRequirementScopes intentionally
carry `bson:"…"` (no `,omitempty`). Mongo's `$set` semantics with
`omitempty` would drop a nil from the update payload and leave a
prior stored value intact — an operator who removed all security
from an API would then see the deleted policy resurface on the next
reload. The non-omitempty tag makes the absent state explicit so
the stored value is overwritten with null.
- apidef/oas/security.go::extractSecurityTo is the source of truth for
these two fields: it resets both at the top of the function and
always emits a definite scope-map slice that mirrors the current OAS
`security:` shape — including for cleared-scope cases where every
alternative carries an empty array. The previous behaviour skipped
the assignment when no alternative had a non-empty scope, which
combined with `omitempty` left stale scopes on disk and, via
normalizeSecurityRequirements, resurrected them on the next fill.
- apidef/oas/security.go::fillSecurity restores per-scheme scope arrays
from SecurityRequirementScopes when present, falling back to empty
arrays otherwise (matching the contract every other auth scheme
follows). OAS 3.0 §4.8.30.1 guard: scope arrays are only emitted for
oauth2 and openIdConnect schemes; every other type round-trips with
an empty array even if upstream data carries scopes.
- normalizeSecurityRequirements is tightened to enrich-only: when the
current SecurityRequirements list is empty it returns nil unchanged
rather than synthesising alternatives from SecurityRequirementScopes'
keys. The synthesis path is preserved for its original purpose
(resizing a stale multi-requirement list to match a freshly-shrunk
scope slice).
- apidef/oas/oauth2.go OAuth2 + OAuth2ScopeCheck types: Enabled,
ClaimNames (default ["scope","scp"] so OAuth and OIDC tokens are
both honored), Separator, ScopeSource. Map-probe disambiguator types
a raw-map scheme as *OAuth2 when it carries the scopeCheck sub-block
key, so JSON round-trips through spec save/load preserve the typed
view. Wire-protocol constants OAuth2ErrInsufficientScope,
OAuth2ErrInvalidToken, OAuth2AuthSchemeBearer (RFC 6750).
- Schema deltas in 4 files (oas, oas-strict, mcp, streams) carry the
scopeCheck sub-block; scopeSource enum constraint mirrors the
apidef validator so the dashboard rejects unknown values at save.
internal/oauth2common
- Pure-function helpers ParseUnverifiedClaims / StringClaim /
StringFromAny used by the gateway scope-check path. No gateway or
EE imports.
Gateway
- OAuth2Middleware reads required scopes from spec.OAS.Security. Each
Security Requirement Object that references the oauth2 scheme
contributes one alternative; scopes within an entry are AND-required;
multiple entries are OR-alternatives. Declared order is preserved
end-to-end so the operator sees their authored form on the failure
challenge. An entry with an empty scope list ("auth required, no
specific scope") is preserved as a vacuously-satisfied alternative
that short-circuits the OR to "pass".
- ScopeSource selects the read path: "global" / "union" (default) read
the root `security:` array; "operation" is a no-op in this path
(per-operation enforcement is handled by a separate middleware).
- EnabledForSpec respects the OAS-level authentication.enabled toggle
so disabling authentication skips the scope check too.
- Emits RFC 6750 §3 / §3.1 Bearer challenges (insufficient_scope,
invalid_token) with stable wire shape — `Bearer SP 1#auth-param` is
pinned by a regex assertion in tests. First alternative is cited on
the challenge so a service-account scope isn't advertised to a
user-token caller.
- Audit event meta carries cited alternative, full alternative list,
granted scopes, jti/azp claim identifiers, path/method, and api_id.
Tests
- apidef/oas/security_test.go TestOAS_SecurityScopes_RoundTrip pins
three contracts: scopes survive extract→fill for oauth2 schemes,
non-oauth schemes get empty scope arrays per OAS spec, legacy data
without SecurityRequirementScopes falls back to empty arrays.
- apidef/oas/security_test.go TestOAS_SecurityScopes_StalePersistence
pins the persistence contract across six edit scenarios: scope
cleared on a single-scheme requirement, security block removed
entirely, multi-requirement with every scope cleared, scope replaced
with a different value, multi-requirement shrunk to single, and
single-requirement extended to multi. Each scenario seeds the
APIDefinition with a stale prior value (the shape the dashboard
would hydrate from Mongo before applying the operator's edit) and
asserts the extract overwrites it.
- gateway/mw_oauth2_test.go covers scope extraction across claim
formats, OR across requirements, OR-of-AND general case, multiple
claimNames merge, default ["scope","scp"] fallback, cited-alternative
order preservation, single-alternative citation, disabled-block and
empty-security inert paths, RFC 6750 §3 header-shape regression
guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
0a3f94b to
5042b9a
Compare
🚨 Jira Linter FailedCommit: The Jira linter failed to validate your PR. Please check the error details below: 🔍 Click to view error detailsNext Steps
This comment will be automatically deleted once the linter passes. |
|



Summary
Adds the OAS-native OAuth 2.0
scopeChecksub-block on the newoauth2security scheme and the gateway middleware that enforces it. Required scopes live in the OAS rootsecurity:array — standard OpenAPI Security Requirement Objects, OR-of-AND across entries. ThescopeCheckblock on the Tyk extension carries only the token-side knobs: which JWT claims to read scopes from, how to split string-form values, and which scope-source mode is active. Validation, scope merging across claims, and the RFC 6750 §3Bearer SP 1#auth-paramfailure challenge all live here.The scheme is name-agnostic — operators can name the OAS
securitySchemesentry anything (oauth2,prctOauth,corpOAuth, …); enforcement uses whatever name the document declares.Story
Stacked on
This PR is stacked on TT-17172 — #8198. Review/merge that first; once it merges this can be retargeted to
master.Companion PRs
These three ship together — please block until all three are green:
tyk: this PRtyk-analytics: TykTechnologies/tyk-analytics#5629tyk-analytics-ui: TykTechnologies/tyk-analytics-ui#4370Behaviour
Apidef (
apidef/oas/)OAuth2ScopeCheck:Enabled,ClaimNames []string,Separator string,ScopeSource string. There is noScopesfield — scope policy lives in OAS rootsecurity:.ScopeSourceconstants:operation,global,union(default).asOAuth2Scheme+mapHasOAuth2SubBlock) tells the OAS-nativeOAuth2scheme apart from legacy*OAuth/*ExternalOAuthschemes after JSON round-trip — recognised by presence of an oauth2 sub-block key.SecurityRequirementScopes [][]map[string][]stringsibling field onapidef.APIDefinitionpreserves per-scheme scope arrays across the extract/fill round-trip (omitempty; forward-compatible).extractSecurityTosingle-alternative branch: populatesSecurityRequirementswith every scheme in the requirement (including empty-scope ones likejwtAuth: []) so multi-scheme single-AND security policies (JWT + OAuth2 in one entry) survive round-trip without silently dropping the JWT scheme.scopeSourceat API load with a clear error.X-Tyk-OAuth2-ScopeCheckwith the four token-side fields.Gateway (
gateway/mw_oauth2.go)OAuth2Middlewareenforces scope check whenoauth2.enabledandscopeCheck.enabledare both true.rootSecurityAlternatives(security, schemeName, scopeCheck)reads rootsecurity:and returns the OR-of-AND list of alternatives drawn from entries that reference the configured scheme. Declared order is preserved end-to-end so the operator sees their authored form on the failure challenge.ClaimNames(default["scope", "scp"]). Each claim value is parsed bySeparator(default single space), JSON array form, or comma-separated form per RFC 6749 §3.3.WWW-Authenticate: Bearer error="insufficient_scope" …challenge citing the first declared alternative only (intentionally — listing every alternative would leak that the API has a service-account path and a user path), JSON body witherror/error_description/scope, and anOAuth2ScopeCheckFailedaudit event carrying the cited alternative + full alternative list.security: - oauth2: []) is treated as "auth required, no specific scope" — vacuously satisfied, short-circuits the OR to pass.Wire-protocol constants
OAuth2ErrInsufficientScope="insufficient_scope"(RFC 6750 §3.1)OAuth2ErrInvalidToken="invalid_token"(RFC 6750 §3.1)Test plan
go test ./apidef/oas/ ./gateway/ ./internal/oauth2common/— unit tests cover: nil/empty/operation-suppression, single-AND, OR-across, OR-of-AND, sort+dedup, vacuous-satisfaction, multi-scheme single-alternative round-trip preservation, name-agnostic enforcement (TestOAuth2Middleware_NameAgnostic), RFC 6750 §3 challenge header formRisk
Failure-challenge form cites only the first declared alternative — deliberate, regression-tested. The strict-schema deltas across four schema files must stay in sync. The map-probe disambiguator's sub-block key list (
oauth2SubBlockKeys) needs an entry added whenever a new OAuth2 sub-block is introduced, otherwise the typed view is silently dropped on JSON round-trip.🤖 Generated with Claude Code
Ticket Details
TT-17173
Generated at: 2026-05-26 15:20:57