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

Screenshots or screen recordings

Not applicable. The consent screen only gains the new scope text.

How to set up and validate locally

  1. In a Rails console, run Feature.enable(:granular_oauth_tokens).
  2. 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]
      )
    )
  3. Run the authorization code flow in the browser against /oauth/authorize; confirm the consent grant row, token.granular? and token.permitted_for_boundary?(Authz::Boundary.for(group), :read_group).
  4. Confirm POST /oauth/token with grant_type=refresh_token keeps scope=granular and rejects scope=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.

🤖 Generated with Claude Code

Edited by Alex Buijs

Merge request reports

Loading
Loading