Enforce S256 PKCE for dynamic clients

Related to gitlab-org/gitlab#602404

Closes gitlab-org/gitlab#604546 (closed)

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:

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 mcp scope 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 mcp scoped token and calls /api/v4/mcp.
  • 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 mcp scoped token and calls /api/v4/mcp.

Screenshots & Recordings

Authz Validation JWT

How to set up and validate locally

Note: In the steps below, GDK is https://gdk.test:8080 and the IAM service is http://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:

  1. Edit configs/environments/development-l2.toml so 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"
  2. Build and start it (Postgres via Docker is required):

    make
    make up
    CONFIG_DIR=$(pwd)/configs bin/iam-auth
  3. Verify: curl http://gdk.test:8084/health returns {"status":"ok"}.

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

  1. 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"
  2. Add the iam_auth_service section to gitlab/config/gitlab.yml under development:, just before # 4. Advanced settings (keys documented in config/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_file must be a full absolute path, as ~ is not expanded. jwt_issuer must match the issuer in the IAM TOML config and jwt_audience the token's aud claim, otherwise JWT validation rejects the token. Note that gdk reconfigure removes this section, because IAM is not yet templated in GDK, so re-add it if needed.

  3. 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/CreateClient

NB: 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:

DCR_1_ownerless_error

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';"
  1. 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"
    }
  2. 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...
  3. 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):

  1. 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"
  2. 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 code query parameter in the address bar.

  3. Store the code from the callback redirect and exchange it for a token. The client is public, so no client secret is sent and client_id goes 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 the code_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.

Edited by Hakeem Abdul-Razak

Merge request reports

Loading
Loading