feat: Add IAM consent flow OAuth authorization
What does this MR do and why?
Implements the GitLab side of the OAuth 2.0 consent challenge flow with the external IAM service. After a user has been authenticated via the login challenge flow (!225853 (merged)), the IAM service redirects the user back to GitLab to authorize the OAuth client and grant the requested scopes. GitLab fetches the consent challenge from the IAM service, renders a consent screen modeled on the existing Doorkeeper authorization page, then accepts or rejects the challenge against the IAM service and redirects the user to the IAM-provided URL.
This MR completes the consent-flow chain. The DB layer (!228884 (merged)) and the service layer (!231426 (merged)) are already merged; this MR adds the controller, view, and route integration that make the flow user-visible.
The implementation is gated behind the existing iam_svc_login feature flag
plus Authn::IamAuthService.enabled?.
Consent challenge flow
sequenceDiagram
actor User
participant Browser
participant IAM as IAM Service
participant Rails as GitLab Rails
Note over User, IAM: Login challenge already accepted (see !225853)
IAM->>Browser: 302 → /-/iam/consent?consent_challenge=<hex64>
Browser->>Rails: GET /-/iam/consent?consent_challenge=<hex64>
Rails->>Rails: require_consent_challenge!
Note right of Rails: Back-channel calls below use<br/>Gitlab-Iam-Auth-Token header
Rails->>IAM: GET /oauth2/internal/auth/requests/consent?consent_challenge=<hex64>
IAM->>Rails: 200 {skip, subject, requested_scope, client: {client_id, ...}}
Rails->>Rails: verify_subject_matches_user!
alt skip == true (trusted client)
Note over Rails: auto-accept on GET /show
Rails->>IAM: PUT /oauth2/internal/auth/requests/consent/accept?challenge=<hex64>
IAM->>Rails: 200 {redirect_to: /oauth2/authorize?consent_verifier=...}
Rails->>Rails: persist Authn::OauthConsent
Rails->>Browser: 303 → IAM redirect URL
else skip == false
Rails->>Browser: Render consent form (client name, scopes, Authorize/Cancel)
alt User clicks Authorize
Browser->>Rails: POST /-/iam/consent/accept
Rails->>Rails: require_consent_challenge!
Rails->>Rails: load_cached_consent_data (from Redis cache)
Rails->>Rails: verify_subject_matches_user!
Rails->>IAM: PUT /oauth2/internal/auth/requests/consent/accept?challenge=<hex64>
IAM->>Rails: 200 {redirect_to: ...}
Rails->>Rails: persist Authn::OauthConsent
Rails->>Browser: 303 → IAM redirect URL
else User clicks Cancel
Browser->>Rails: POST /-/iam/consent/reject
Rails->>Rails: require_consent_challenge!
Rails->>Rails: load_cached_consent_data (from Redis cache)
Rails->>Rails: verify_subject_matches_user!
Rails->>IAM: PUT /oauth2/internal/auth/requests/consent/reject?challenge=<hex64>
IAM->>Rails: 200 {redirect_to: /oauth2/authorize?error=access_denied}
Rails->>Browser: 303 → IAM redirect URL
end
endImplementation notes
- Routes:
Iam::ConsentControlleris mounted under the global/-/scope at/-/iam/consent,/-/iam/consent/accept,/-/iam/consent/reject. Route definitions live inconfig/routes/iam.rb, mirroring theconfig/routes/jira_connect.rbpattern. - Persistence:
Authn::OauthConsentrecords are created on accept (will also be introduced on reject in a follow-up MR).User#oauth_consentscascades on destroy. - Identity binding:
verify_subject_matches_user!runs before every action to guarantee the IAM-reportedsubjectmatchescurrent_user.id.
References
Consent flow MRs:
- Add oauth_consents table and model (!228884 - merged):
oauth_consentstable and model (DB layer, prerequisite). - Add IAM service HTTP client and OAuth consent f... (!231426 - merged): IAM service HTTP client and consent flow services (prerequisite).
- feat: Add IAM consent flow OAuth authorization (!232772 - merged): this MR, consent controller, view, and route integration.
Configuration prerequisite:
- Update IAM service config to top-level iam_auth... (!230039 - merged): IAM service top-level configuration (
iam_auth_service).
Parent flow (login):
- Login Integration with IAM Service (!225853 - merged): IAM login challenge integration.
External:
- Main work item: GitLab Rails: Consent Integration with IAM Service (#589572)
- OAuth authorization code flow with IAM Service: https://gitlab.com/gitlab-org/auth/iam/-/merge_requests/110/diffs
Follow-up work
- Audit events for consent accept/reject: Emit audit events on IAM consent accept and reject (!235073 - merged)
- Reject persistence + data-model changes: Persist consent rejection records in OauthConsent (!236304)
Screen recordings
| Case | Recording |
|---|---|
| Accept consent (from unauthenticated user) | |
| Reject consent | |
| Error with IAM |
How to set up and validate locally
Prerequisites
Setup is centralized in the IAM service wiki:
Setup: register an OAuth client (required)
-
On GitLab: register at
http://gdk.test:3000/-/user_settings/applications- Redirect URI:
http://gdk.test:3000/callback - Confidential: no
- Scopes:
read_user,read_api,profile,email
- Redirect URI:
-
Capture the resulting
client_idandclient_secretin shell variables:CLIENT_ID="<from GitLab>" CLIENT_SECRET="<from GitLab>" SCOPES="read_user read_api profile email" OWNER="<owner associated to the oauth application>" REDIRECT_URI="http://gdk.test:3000/callback" -
Mirror the registration on IAM (the IAM service does not currently sync OAuth clients from GitLab):
curl -s -X POST http://gdk.test:8084/oauth2/internal/clients \ -H "Content-Type: application/json" \ -H "Gitlab-Iam-Auth-Token: dev-service-token-do-not-use-in-production" \ -d "{ \"client_id\": \"${CLIENT_ID}\", \"client_secret\": \"${CLIENT_SECRET}\", \"redirect_uris\": [\"${REDIRECT_URI}\"], \"grant_types\": [\"authorization_code\", \"refresh_token\"], \"response_types\": [\"code\"], \"scopes\": [\"read_user\", \"read_api\", \"profile\", \"email\"], \"client_name\": \"Test application for consent flow\", \"owner\": \"${OWNER}\", \"public\": false }" -
Generate PKCE parameters:
export CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=' | tr '+/' '-_') export CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
Normal flow: via browser
- Build the authorize URL and open it in a browser:
echo "http://gdk.test:8084/oauth2/authorize?\
client_id=${CLIENT_ID}&\
redirect_uri=${REDIRECT_URI}&\
response_type=code&\
scope=$(echo "$SCOPES" | tr ' ' '+')&\
state=my-random-state&\
code_challenge=${CODE_CHALLENGE}&\
code_challenge_method=S256"- After signing in (login challenge), you will be redirected to
/-/iam/consent?consent_challenge=<hex64>. - Verify the consent screen renders the client name, scopes, and an admin warning if you are an admin.
- Click Authorize and verify the redirect to the IAM-provided URL, and that an
Authn::OauthConsentrecord is created (Authn::OauthConsent.last). - Click Cancel in a fresh flow and verify the redirect with
?error=access_deniedand that no record is created.
Testing edge cases
- Subject mismatch: Sign in as user A, capture a consent challenge URL, then in another browser sign in as user B and visit the same URL. Verify the error page is shown and no record is persisted.
- Invalid
consent_challenge: Visit/-/iam/consentwith noconsent_challengeor a >10KB value. Verify the error page is shown. - IAM service down: Stop the IAM service and visit the consent URL. Verify the error page is shown.
Note: Trusted client (auto-accept) cannot be tested without an hacky way, as the current implementation of IAM service hardcodes to false the skip value.
To test it: hardcode the skip value to true, recompile the application and verify the consent screen does not render and the user is redirected straight through.
Extra (manual): exchange the code for an access token
After clicking Authorize, the browser is redirected to the OAuth callback URL with a code query parameter. The OAuth client application would normally exchange this code for an access token; here we do it manually to prove the end-to-end flow:
# Take the code from the &code=... in the browser URL after Authorize.
export AUTH_CODE="<paste-from-browser>"
curl -s -X POST http://gdk.test:8084/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-u "${CLIENT_ID}:${CLIENT_SECRET}" \
-d "grant_type=authorization_code&code=${AUTH_CODE}&redirect_uri=${REDIRECT_URI}&code_verifier=${CODE_VERIFIER}" \
| jq .Verify the response contains access_token, refresh_token, and the granted scope.
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.