Mint granular OAuth tokens at authorization and refresh
What does this MR do and why?
Closes #608636. This replaces !257024 (closed), which held all of this work before the split. Everything is behind the default-off feature flag granular_oauth_tokens, added in !260049 (merged).
This MR targets the 608636-oauth-consent-grant-services branch of !260051 until that merges. It depends on !260050 (merged) because refresh needs granular to be a configured scope, and on !260051 because the controller calls Authz::OauthConsentGrants::CreateService.
When a user approves a granular OAuth application, Doorkeeper now writes a consent grant. The code and every token minted from it carry only the granular scope, at code exchange and at refresh.
In Oauth::AuthorizationsController, pre_auth_params forces the requested scope to granular for a granular application (flag on, declared set non-empty), ahead of the MCP forced scope. create records the grant before Doorkeeper issues the code; on failure it renders the error page with status 400 and memoizes the error as the authorization response, so the after-actions issue no code and audit nothing. The error text is the new granular_consent_failed string in config/locales/doorkeeper.en.yml. Trusted applications skip the screen and get a trusted_auto_grant grant from the declared set when none is active. The confidential-application skip for a matching live token now also requires an active grant for granular applications, otherwise the consent screen shows.
Clients cannot obtain granular for a non-granular application, and the server forces it for a granular one. Refresh cannot widen, because Doorkeeper validates requested refresh scopes against the old token's scopes. Refresh relies on !260050 (merged), which configures granular as a Doorkeeper scope.
Request specs cover the authorizations controller, token refresh, and an end-to-end authorization code flow. There is no changelog while the flag is default-off.
Several decisions are recorded on the work item. Grants are granular-only (no record-only grant for legacy authorizations), the per-application declaration is the request contract, and forcing only granular drops openid, so a granular application gets no ID token in phase 1.
References
- Work item: #608636
- Technical design: https://gitlab.com/gitlab-org/gitlab/-/work_items/605869
- Parent epic: #468924
- Design proposal 017: https://gitlab.com/gitlab-org/architecture/auth-architecture/design-doc/-/merge_requests/136
- Stack base: !257023 (merged)
- Feature flag: !260049 (merged)
- Granular Doorkeeper scope: !260050 (merged)
- Consent grant services: !260051
- Replaced MR: !257024 (closed)
Screenshots or screen recordings
Not applicable. The consent screen only gains the new scope text.
How to set up and validate locally
- In a Rails console, run
Feature.enable(:granular_oauth_tokens). - Declare scopes on an application created with
scopes: 'api granular':application.oauth_application_granular_scopes.create!( granular_scope: Authz::GranularScope.create!( organization: group.organization, namespace: group, access: :selected_memberships, permissions: [:read_group] ) ) - Run the authorization code flow in the browser against
/oauth/authorize; confirm the consent grant row,token.granular?andtoken.permitted_for_boundary?(Authz::Boundary.for(group), :read_group). - Confirm
POST /oauth/tokenwithgrant_type=refresh_tokenkeepsscope=granularand rejectsscope=granular api.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.