Enforce S256 PKCE for dynamic clients
Related to gitlab-org/gitlab#602404
Closes gitlab-org/gitlab#604546 (closed)
- MR 1 of 3: !602 (merged) (add dynamic client column)
- MR 2 of 3 (this MR)
- MR 3 of 3: !560 (merged) (register endpoint)
Summary
To allow MCP clients to automatically authenticate to GitLab and access the GitLab MCP server, groupapi platform rolled out dynamically registered OAuth applications (GitLab DCR docs).
DCR lets an OAuth client register itself with the authorization server over an API, with no human pre-creating an application (unlike our typical OAuth Applications).
Since DCR per the MCP authorization spec should be handled by the authorization server (IAM), this MR is one of several implementing gitlab-org/gitlab#602404 in the IAM service, after the dynamic column landed in !602 (merged).
What does this MR do and why?
What?
Enforce PKCE with S256 for dynamic clients during the authorization code flow per the MCP authorization spec:
- Reuses the existing LegacyPKCEHandler plus an early check on /oauth/authorize to require PKCE with S256 before the login redirect to Rails for user consent.
- The carryover to Rails makes use of InternalOAuthClientsService and the consent challenge in the IAM service to reach Rails' consent page.
- Rails DCR mirroring:
- Matches Rails' PKCE enforcement and scope forcing, so a dynamic client behaves the same on either provider.
- Even though the PRM document advertises each MCP resource's allowed scope, bad mcp-clients like gitlab-org/gitlab#585699 (closed) might send non-mcp related scopes so force-scoping fixed that behaviour.
- Matches Rails' PKCE enforcement and scope forcing, so a dynamic client behaves the same on either provider.
Why?
- Before, per the GitLab OAuth 2.0 docs:
- A user working in OpenCode asks it to list their open merge requests.
- Before OpenCode can start the OAuth flow, the user has to leave it and create an OAuth application by hand.
- Confidential stays unchecked and there is no secret, MCP clients are public and use PKCE.
- The user picks the
mcpscope and copies the application's client id into OpenCode's MCP configuration (opencode.json). - The user signs in and consents once.
- The agent gets its
mcpscoped token and calls/api/v4/mcp.
- A user working in OpenCode asks it to list their open merge requests.
- After:
- A user working in OpenCode asks it to list their open merge requests.
- OpenCode registers itself through
POST /oauth/register, no manual application setup. - The user signs in and consents once.
- The agent gets its
mcpscoped token and calls/api/v4/mcp.
- OpenCode registers itself through
- A user working in OpenCode asks it to list their open merge requests.
Screenshots & Recordings
| Authz Validation | JWT |
|---|---|
How to set up and validate locally
Note: In the steps below, GDK is
https://gdk.test:8080and the IAM service ishttp://gdk.test:8084. Adjust the URLs to match your local configuration.
Steps
1. Setup IAM and GDK
Full setup is documented in the IAM project:
The condensed steps for this MR
Run the IAM service locally
From a checkout of gitlab-org/auth/iam, following iam-auth-local-setup.md:
-
Edit
configs/environments/development-l2.tomlso the service points at your GDK, in particular:[services.auth.oauth] issuer = "https://gdk.test" login_url = "https://gdk.test:8080/users/sign_in" consent_url = "https://gdk.test:8080/-/iam/consent" [services.auth.oauth_client] gitlab_url = "https://gdk.test:8080" -
Build and start it (Postgres via Docker is required):
make make up CONFIG_DIR=$(pwd)/configs bin/iam-auth -
Verify:
curl http://gdk.test:8084/healthreturns{"status":"ok"}. -
If you use Chrome, apply the secure-cookie flag workaround now. Without it the authorization code flow in step 4 fails with
missing login csrf cookie.
Point GDK Rails at the IAM service
Following gitlab-rails-local-setup.md, which also enables the iam_svc_oauth feature flag:
-
Create the shared secret file:
mkdir -p "$HOME/.gdk/iam-auth" echo "dev-service-token-do-not-use-in-production" > "$HOME/.gdk/iam-auth/.gitlab_iam_auth_secret" -
Add the
iam_auth_servicesection togitlab/config/gitlab.ymlunderdevelopment:, just before# 4. Advanced settings(keys documented inconfig/gitlab.yml.example):iam_auth_service: enabled: true secret_file: /full/path/to/home/.gdk/iam-auth/.gitlab_iam_auth_secret http: host: "gdk.test" port: 8084 jwt_audience: "gitlab-rails" jwt_issuer: "https://gdk.test"The
secret_filemust be a full absolute path, as~is not expanded.jwt_issuermust match theissuerin the IAM TOML config andjwt_audiencethe token'saudclaim, otherwise JWT validation rejects the token. Note thatgdk reconfigureremoves this section, because IAM is not yet templated in GDK, so re-add it if needed. -
gdk restart
2. Seed a dynamic client
The HTTP internal clients endpoints are deprecated, so use gRPC (brew install grpcurl) with the development service token (configs/environments/secrets.toml):
grpcurl -plaintext \
-H "gitlab-iam-auth-token: dev-service-token-do-not-use-in-production" \
-d '{
"client_id": "dynamic-test-client",
"client_secret": "",
"redirect_uris": ["http://localhost:1234/cb"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scopes": ["mcp"],
"public": true,
"dynamic": true,
"owner": "root",
"client_name": "[Unverified Dynamic Application] test",
"created_at": "2026-08-10T00:00:00Z",
"updated_at": "2026-08-10T00:00:00Z"
}' \
localhost:5004 gitlab.iam.auth.v1.InternalOAuthClientsService/CreateClientNB: a real DCR client has an empty owner, but the consent challenge validation on gitlab-rails still rejects a blank client_owner. Keep the owner field until gitlab-org/gitlab#623570 (closed) is closed. Without an owner, step 4.1 shows this consent page:
NB: Run the foreign key removal migration before step 4.
Confirm the row:
PGPASSWORD=iam-service psql -h localhost -p 5430 -U iam-service iam-service \
-c "SELECT id, public, dynamic FROM oauth_clients WHERE id = 'dynamic-test-client';"3. Validate the DCR IAM Authz Enforcement Before Redirect to GDK Consent
-
Generate and store the request constants and PKCE pair used by the validations below:
# scope types EMPTY_SCOPE="" NON_MCP_SCOPE="api" MCP_SCOPE="mcp" # code_challenge_method types EMPTY_PKCE="" PLAIN_PKCE="plain" S256_PKCE="S256" # PKCE pair (code_verifier and its S256 code_challenge) VERIFIER=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-43) S256_CHALLENGE=$(printf %s "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=') EMPTY_CHALLENGE="" # base IAM authorize request; set SCOPE, CHALLENGE and PKCE before each call authorize() { curl -i "http://gdk.test:8084/oauth/authorize?client_id=dynamic-test-client&redirect_uri=http://localhost:1234/cb&response_type=code&state=localteststate&scope=$SCOPE&code_challenge=$CHALLENGE&code_challenge_method=$PKCE" } -
Verify the new DCR enforcement authz errors and hints for bad requests:
# Missing code_challenge SCOPE=$MCP_SCOPE CHALLENGE=$EMPTY_CHALLENGE PKCE=$S256_PKCE authorize # http://localhost:1234/cb?error=invalid_request&error_description=...S256... # plain code_challenge_method SCOPE=$MCP_SCOPE CHALLENGE=$S256_CHALLENGE PKCE=$PLAIN_PKCE authorize # http://localhost:1234/cb?error=invalid_request&error_description=...S256... # Missing code_challenge_method SCOPE=$MCP_SCOPE CHALLENGE=$S256_CHALLENGE PKCE=$EMPTY_PKCE authorize # http://localhost:1234/cb?error=invalid_request&error_description=...S256... -
Verify the new DCR enforcement for good requests:
# Silently re-uses the existing scope in oauth_clients # as opposed to sending an invalid_scope error, to match Rails DCR. # In the 2nd MR, only mcp/mcp_orbit will be allowed as possible scopes. SCOPE=$NON_MCP_SCOPE CHALLENGE=$S256_CHALLENGE PKCE=$S256_PKCE authorize # https://gdk.test:8080/users/sign_in?login_challenge=... # An empty scope gets the registered scope too SCOPE=$EMPTY_SCOPE authorize # https://gdk.test:8080/users/sign_in?login_challenge=... # The registered mcp scope passes unchanged SCOPE=$MCP_SCOPE authorize # https://gdk.test:8080/users/sign_in?login_challenge=...
4. Obtain an IAM-issued JWT using authorization code flow with PKCE
Run the authorization code flow with PKCE against the IAM service, the same flow as in gitlab-org/gitlab!246466 (merged):
-
Open the good S256 authorize URL from step 3 in a browser, sign in, and consent:
echo "http://gdk.test:8084/oauth/authorize?client_id=dynamic-test-client&redirect_uri=http://localhost:1234/cb&response_type=code&state=localteststate&scope=$MCP_SCOPE&code_challenge=$S256_CHALLENGE&code_challenge_method=$S256_PKCE" -
The browser lands back on the redirect URI. The rendered page does not matter and can be a 404 unless that URI serves an HTML page. What matters is the
codequery parameter in the address bar. -
Store the
codefrom the callback redirect and exchange it for a token. The client is public, so no client secret is sent andclient_idgoes in the body:CODE="" curl -X POST http://gdk.test:8084/oauth/token \ -d "grant_type=authorization_code&client_id=dynamic-test-client&code=$CODE&redirect_uri=http://localhost:1234/cb&code_verifier=$VERIFIER" # 200 with access_token (the IAM JWT), refresh_token, "scope": "mcp"Codes are single use, so repeat the browser step, store the fresh
CODE, and exchange it without thecode_verifier:curl -X POST http://gdk.test:8084/oauth/token \ -d "grant_type=authorization_code&client_id=dynamic-test-client&code=$CODE&redirect_uri=http://localhost:1234/cb" # 400 {"error":"invalid_request","error_description":"...The PKCE code verifier is required..."}
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.
