Mint granular OAuth tokens at authorization and refresh

What does this MR do and why?

Closes #608636. Stacked on !257023 (merged) and targeting its branch until that merges. Everything is behind the new default-off feature flag granular_oauth_tokens (type wip).

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.

Authz::OauthConsentGrants::CreateService copies the application's declared scopes into a new authorized grant in the user's organization and marks any previous active grant revoked, keeping it as history. On first consent it revokes the pair's outstanding tokens and codes, so a broad token minted before the application opted in cannot outlive the granular consent. On re-consent it revokes nothing, since outstanding granular tokens resolve the new grant on their next request. It emits oauth_consent_grant_created or oauth_consent_grant_amended, rejects the duo_session source for now, and returns ActiveRecord::RecordNotUnique from the partial unique index as a conflict.

Authz::OauthConsentGrants::RevokeService revokes the active grant plus the pair's tokens and codes and emits oauth_consent_grant_revoked. It is idempotent without an active grant and not flag-gated, so revocation keeps working if the flag is later disabled. Authn::OauthApplications::RevokeService (the authorized applications page) calls it inside its existing transaction. Revoking a single token (RFC 7009) leaves the grant intact, admin deletion of an application cascades to its grants, and user blocking is unchanged since blocked users' tokens are already rejected at authentication.

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. 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.

granular is a configured Doorkeeper scope again through Gitlab::Auth.optional_scopes, partially reverting !212804 (merged). Doorkeeper 5.9 refreshes a token with the intersection of the old token's scopes and the application's configured scopes when the client sends no scope, so granular must be on the application's scopes column and therefore configured, or a refresh yields a token with no scopes. The marker stays hidden from the application form (excluded_scopes), is stripped if submitted, is kept when an application with a declared set is edited, and is removed from the OIDC discovery scopes_supported list in JwksController. Clients cannot obtain granular for a non-granular application, the server forces it for a granular one, and refresh cannot widen because Doorkeeper validates requested refresh scopes against the old token's scopes.

Also included: the model scope Authz::OauthConsentGrant.for_user_and_application, locale entries for the granular scope and the consent error (gettext catalog updated), and three audit event type definitions (feature category permissions, user scoped) with the regenerated doc/user/compliance/audit_event_types.md. 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.

Database

No migrations. All tables involved stay empty on GitLab.com until the flag is enabled.

The plans below ran on a Database Lab clone of gitlab-production-main (session, commands 509291 to 509301). oauth_application_granular_scopes comes from !257023 (merged), so it was created on the clone from db/structure.sql on this branch. It and oauth_consent_grants, which is empty in production, were seeded with synthetic rows (1.5M active grants, 300k revoked grants, 20k declared scope rows) and analyzed.

Active grant lookup. Used by both consent grant services and Oauth::AuthorizationsController#active_consent_grant. Served by the partial unique index: 10 shared buffer hits, 0.09 ms.

SELECT "oauth_consent_grants".* FROM "oauth_consent_grants" WHERE "oauth_consent_grants"."status" = 0 AND "oauth_consent_grants"."source" != 3 AND "oauth_consent_grants"."user_id" = 1614863 AND "oauth_consent_grants"."application_id" = 1000 ORDER BY "oauth_consent_grants"."id" ASC LIMIT 1;
Plan
 Limit  (cost=3.46..3.46 rows=1 width=52) (actual time=0.088..0.090 rows=1 loops=1)
   Buffers: shared hit=10
   I/O Timings: read=0.000 write=0.000
   ->  Sort  (cost=3.46..3.46 rows=1 width=52) (actual time=0.088..0.088 rows=1 loops=1)
         Sort Key: oauth_consent_grants.id
         Sort Method: quicksort  Memory: 25kB
         Buffers: shared hit=10
         I/O Timings: read=0.000 write=0.000
         ->  Index Scan using idx_oauth_consent_grants_on_user_id_and_application_id_active on public.oauth_consent_grants  (cost=0.43..3.45 rows=1 width=52) (actual time=0.071..0.071 rows=1 loops=1)
               Index Cond: ((oauth_consent_grants.user_id = 1614863) AND (oauth_consent_grants.application_id = 1000))
               Buffers: shared hit=7
               I/O Timings: read=0.000 write=0.000
Settings: effective_cache_size = '472585MB', work_mem = '230MB', jit = 'off', random_page_cost = '1.5', seq_page_cost = '4'
Query ID: 547223618636152664

Revoking the previous grant. grant.revoked! on re-consent and revocation. Primary key lookup: 27 shared buffer hits, 0.3 ms.

UPDATE "oauth_consent_grants" SET "status" = 1, "updated_at" = '2026-10-05 10:00:00' WHERE "oauth_consent_grants"."id" = 1800001;
Plan
 Update on public.oauth_consent_grants  (cost=0.43..3.45 rows=0 width=0) (actual time=0.306..0.307 rows=0 loops=1)
   Buffers: shared hit=27
   WAL: records=5 fpi=0 bytes=335
   I/O Timings: read=0.000 write=0.000
   ->  Index Scan using oauth_consent_grants_pkey on public.oauth_consent_grants  (cost=0.43..3.45 rows=1 width=16) (actual time=0.084..0.085 rows=1 loops=1)
         Index Cond: (oauth_consent_grants.id = 1800001)
         Buffers: shared hit=7
         I/O Timings: read=0.000 write=0.000
Settings: random_page_cost = '1.5', seq_page_cost = '4', effective_cache_size = '472585MB', work_mem = '230MB', jit = 'off'
Query ID: -766880889236656925

Revoking the pair's tokens. Doorkeeper's revoke_tokens_and_grants_for, run on first consent and on revocation. The authorized applications page already runs the same statement. Uses partial_index_user_id_app_id_created_at_token_not_revoked: 4 buffers read, 6.9 ms cold.

UPDATE "oauth_access_tokens" SET "revoked_at" = '2026-10-05 10:00:00' WHERE "oauth_access_tokens"."resource_owner_id" = 1614863 AND "oauth_access_tokens"."application_id" = 1000 AND "oauth_access_tokens"."revoked_at" IS NULL;
Plan
 Update on public.oauth_access_tokens  (cost=0.56..3.58 rows=0 width=0) (actual time=6.917..6.918 rows=0 loops=1)
   Buffers: shared read=4
   I/O Timings: read=6.867 write=0.000
   ->  Index Scan using partial_index_user_id_app_id_created_at_token_not_revoked on public.oauth_access_tokens  (cost=0.56..3.58 rows=1 width=14) (actual time=6.916..6.916 rows=0 loops=1)
         Index Cond: ((oauth_access_tokens.resource_owner_id = 1614863) AND (oauth_access_tokens.application_id = 1000))
         Filter: (oauth_access_tokens.revoked_at IS NULL)
         Buffers: shared read=4
         I/O Timings: read=6.867 write=0.000
Settings: work_mem = '230MB', jit = 'off', random_page_cost = '1.5', seq_page_cost = '4', effective_cache_size = '472585MB'
Query ID: 412297368697802298

Revoking the pair's codes. The second half of revoke_tokens_and_grants_for. Uses index_oauth_access_grants_on_resource_owner_id: 4 buffers read, 6.4 ms cold.

UPDATE "oauth_access_grants" SET "revoked_at" = '2026-10-05 10:00:00' WHERE "oauth_access_grants"."resource_owner_id" = 1614863 AND "oauth_access_grants"."application_id" = 1000 AND "oauth_access_grants"."revoked_at" IS NULL;
Plan
 Update on public.oauth_access_grants  (cost=0.57..3.59 rows=0 width=0) (actual time=6.393..6.394 rows=0 loops=1)
   Buffers: shared read=4
   I/O Timings: read=6.357 write=0.000
   ->  Index Scan using index_oauth_access_grants_on_resource_owner_id on public.oauth_access_grants  (cost=0.57..3.59 rows=1 width=14) (actual time=6.391..6.392 rows=0 loops=1)
         Index Cond: ((oauth_access_grants.resource_owner_id = 1614863) AND (oauth_access_grants.application_id = 1000))
         Filter: (oauth_access_grants.revoked_at IS NULL)
         Buffers: shared read=4
         I/O Timings: read=6.357 write=0.000
Settings: seq_page_cost = '4', effective_cache_size = '472585MB', work_mem = '230MB', jit = 'off', random_page_cost = '1.5'
Query ID: -8430799704223654803

Declared set check. Authn::OauthApplication#granular?, run on authorization and when an existing application's scopes change. Index-only scans on both tables: 10 shared buffer hits, 0.04 ms.

SELECT 1 AS one FROM "granular_scopes" INNER JOIN "oauth_application_granular_scopes" ON "granular_scopes"."id" = "oauth_application_granular_scopes"."granular_scope_id" WHERE "oauth_application_granular_scopes"."application_id" = 1000 LIMIT 1;
Plan
 Limit  (cost=0.71..1.61 rows=1 width=4) (actual time=0.038..0.039 rows=0 loops=1)
   Buffers: shared hit=10
   I/O Timings: read=0.000 write=0.000
   ->  Merge Join  (cost=0.71..2.51 rows=2 width=4) (actual time=0.036..0.037 rows=0 loops=1)
         Merge Cond: (granular_scopes.id = oauth_application_granular_scopes.granular_scope_id)
         Buffers: shared hit=10
         I/O Timings: read=0.000 write=0.000
         ->  Index Only Scan using granular_scopes_pkey on public.granular_scopes  (cost=0.42..9021.81 rows=377498 width=8) (actual time=0.009..0.010 rows=2 loops=1)
               Heap Fetches: 0
               Buffers: shared hit=4
               I/O Timings: read=0.000 write=0.000
         ->  Index Only Scan using idx_oauth_application_granular_scopes_on_app_id_scope_id on public.oauth_application_granular_scopes  (cost=0.29..1.82 rows=2 width=8) (actual time=0.024..0.024 rows=1 loops=1)
               Index Cond: (oauth_application_granular_scopes.application_id = 1000)
               Heap Fetches: 0
               Buffers: shared hit=6
               I/O Timings: read=0.000 write=0.000
Settings: work_mem = '230MB', jit = 'off', random_page_cost = '1.5', seq_page_cost = '4', effective_cache_size = '472585MB'
Query ID: 7524475340615770377

The services also insert one row into oauth_consent_grants and, per declared scope, one row each into granular_scopes and oauth_consent_grant_granular_scopes.

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.
  5. Revoke the application on /-/user_settings/applications; confirm the grant and tokens are revoked and the audit event is written.
  6. Run the specs: spec/services/authz/oauth_consent_grants/, spec/services/authn/oauth_applications/revoke_service_spec.rb, spec/requests/oauth/authorizations_controller_spec.rb, spec/requests/oauth/tokens_controller_spec.rb, spec/requests/oauth/flows/granular_authorization_code_spec.rb, spec/requests/jwks_controller_spec.rb, spec/initializers/doorkeeper_spec.rb, spec/lib/gitlab/auth_spec.rb, spec/controllers/oauth/applications_controller_spec.rb.

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