feat: validate and persist OAuth2 authorization scope - #28045
Draft
BobbyHo wants to merge 2 commits into
Draft
Conversation
/oauth2/authorize ignored the scope parameter entirely and wrote a hardcoded coder:all onto every authorization code. It now negotiates: each requested scope must be in the external scope catalog (rbac.IsExternalScope), and the result must fall within the app's configured allowlist, which is itself filtered through the same catalog. An omitted scope defaults to the filtered allowlist per RFC 6749 section 3.3. The negotiated value is persisted on oauth2_provider_app_codes.scope, replacing the placeholder written when the column was added. Both GET and POST validate, so a request that cannot succeed is rejected before the consent page renders rather than after the user clicks Allow. This matches how the handler already treats PKCE's code_challenge requirement. Two cases produce an empty result and are handled deliberately differently. An app with no allowlist and no requested scope keeps today's unrestricted grant, spelled as the explicit coder:all sentinel because the column is NOT NULL with a non-empty CHECK. An app whose allowlist filters to nothing is rejected instead, since falling back there would grant strictly more than the allowlist ever permitted. NULL and the empty string are one "no allowlist configured" state, unified in a single predicate now that reading the column is an authorization decision. Accepted compatibility break: dynamic client registration performs no catalog validation, so apps registered with scopes such as openid or admin hold allowlists this server cannot grant from. Those apps now fail authorization in both directions with invalid_scope. Grandfathering unknown names through would seed api_keys.scopes with values dbauthz cannot evaluate, trading a visible negotiation-time error for a silent enforcement-time hole. Registration-time expectations are unchanged; the two tests asserting registration accepts these values carried comments promising the opposite of what authorization does, and those were corrected. Issued tokens are not yet restricted: authorizationCodeGrant still mints rbac.ScopeAll and does not read the persisted column. That lands with the grant path. Refs PLAT-479
…lat-470' into coder-oauth2-scope-enforcement-plat-470-phrase-2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
/oauth2/authorizeparses thescopeparameter and then discards it (// TODO: Ignoring scope for now), so an app's configured allowlist has never restricted anything and a client asking for more than it should be granted is never told no. Phase 1 added the columns that carry a negotiated scope from a code to the token it becomes, but nothing negotiates one yet:authorize.gowrites a hardcodedcoder:allonto every code it issues.This PR makes the authorize step negotiate.
validateRequestedScopechecks every requested scope name against the external scope catalog (rbac.IsExternalScope), filters the app's stored allowlist through that same catalog, and requires the request to be a subset of what survives. An omittedscopedefaults to the filtered allowlist per RFC 6749 section 3.3. The result is persisted onoauth2_provider_app_codes.scope, replacing Phase 1's placeholder. Both handlers validate, so a request that cannot succeed is rejected before the consent page renders rather than after the user clicks Allow, matching how this file already treats PKCE'scode_challenge.Two paths can produce an empty result, and they are deliberately not the same path. An app with no allowlist and no requested scope keeps today's unrestricted grant, written as the explicit
coder:allsentinel because the column isNOT NULLwith a non-emptyCHECK. An app whose allowlist filters to nothing is rejected instead, since falling back there would grant strictly more than the allowlist ever permitted.NULLand''are one "no allowlist configured" state, unified in a single predicate now that reading that column is an authorization decision rather than a display detail.This carries an accepted compatibility break. Dynamic client registration performs no catalog validation, so apps registered with scopes such as
openid,read, oradminhold allowlists this server cannot grant from. Those apps now fail authorization in both directions: requesting the scope they registered is rejected, and omittingscopehits the filtered-to-empty rejection. Grandfathering unknown names through would seedapi_keys.scopeswith valuesdbauthzcannot evaluate, trading a visible negotiation-time error for a silent enforcement-time hole. The affected population is bounded to DCR self-registered apps that sent ascope, the failure is immediate and carries a standardinvalid_scope, and the remedy is re-registration with catalog scopes.Issued tokens are still unrestricted:
authorizationCodeGrantcontinues to mintrbac.ScopeAlland does not yet read the persisted column. This PR changes which authorization requests succeed, not what a token can do.Phase 2 of PLAT-470, tracked as PLAT-479. Stacked on #28007. Applying the negotiated scope in
authorizationCodeGrant, refresh narrowing, and the docs update follow as separate PRs.Where this sits in the scope pipeline (green marks what this PR touches)
flowchart TD subgraph authorize["/oauth2/authorize"] AZ1["ShowAuthorizePage (GET)<br/>validates scope, then renders consent"] AZ2["ProcessAuthorize (POST)<br/>validates scope, then issues code"] V["validateRequestedScope<br/>catalog check + allowlist subset"] AZ1 --> V AZ2 --> V end APP[("oauth2_provider_apps.scope<br/>the allowlist, read as input")] APP --> V V --> CODES[("oauth2_provider_app_codes.scope<br/>negotiated value, was a placeholder")] subgraph codegrant["POST /oauth2/tokens, authorization_code"] G1["authorizationCodeGrant<br/>still hardcoded to rbac.ScopeAll"] end CODES --> G1 G1 --> TOKENS[("oauth2_provider_app_tokens.scope")] subgraph enforce["Every authenticated API request"] E1["httpmw ExtractAPIKey"] --> E2["APIKey.ScopeSet()"] --> E3["UserRBACSubject"] --> E4["dbauthz authorize"] end TOKENS --> E1 classDef changed fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#1b3c1e class AZ1,AZ2,V,CODES changedGreen is added or changed here. The grant path and the enforcement engine below it are untouched: they already read a key's scopes correctly and are waiting on real data, which the next PR feeds them.
How to review this PR
coderd/oauth2provider/authorize.go: the whole behavior change.noScopeAllowlistfirst, since it defines what "no allowlist" means, thenvalidateRequestedScope's four branches, then the two call sites. The catalog check runs on the request before any allowlist logic, so an internal-only name likedebug_info:readis rejected whether or not the app has an allowlist. That check is a curation, not a validity check: RBAC expands such names fine and theapi_key_scopeenum stores them, which is exactly why the 55-name catalog is narrower than both.coderd/oauth2provider/authorize_internal_test.go: the table covers the four branches plus the cases that pin the contract the signature cannot. A rejection returns an empty string and a success never does, because the value goes straight to aCHECK (scope <> '')column.coderd/oauth2provider/authorize_test.go: HTTP-level coverage against a real database, asserting the persisted column by parsing the issued code's prefix out of the redirect rather than inferring the grant.TestOAuth2AuthorizeDCRScopeCompatibilitypins the break above in executable form, registering through DCR because that is the only route that produces a non-catalog allowlist naturally.coderd/oauth2provider/validation_test.goandcoderd/oauth2_metadata_validation_test.go: comments only. Both files carried a near-duplicateTestOAuth2ClientScopeValidationasserting registration acceptsadmin, commented "should be allowed but validated during authorization." That promised the opposite of what authorization now does. Registration-time expectations are unchanged, since this PR adds no registration-time validation.Verified locally: the full
coderd/oauth2providerpackage,coderd'sTestOAuth2*suites, andcoderd/mcpall pass against Postgres, andgolangci-lintis clean on both changed packages.