Implement /oauth/register endpoint on IAM

Related to gitlab-org/gitlab#602404

Closes gitlab-org/gitlab#623569 (closed)

  • MR 1 of 3: !602 (merged) (add dynamic client column)
  • MR 2 of 3: !473 (merged) (enforce PKCE for dynamic clients)
  • MR 3 of 3 (this MR)
Previous POC work

Screenshots & Recordings

Higher resolution: https://drive.google.com/file/d/12K6_6jUwtbL8FETeU9ZTvfcVZxqeiI9V/view?usp=sharing

Demo

Add RFC 7591 dynamic client registration endpoint

Findings the POC surfaced (for the issue)

  1. The IAM consent path rejects ownerless dynamic clients
  2. Consent acceptance FK-violates for IAM-native clients

Summary

See !473 (merged). This MR builds off it (stacked on its branch) to implement the registration endpoint on IAM.

Per the MCP authorization spec, the authorization server should support DCR so MCP clients can register themselves with no human pre-creating an application.

GitLab Rails serves POST /oauth/register today (GitLab DCR docs). This MR moves registration onto the authorization server (IAM).

What does this MR do and why?

What?

  • Implements RFC 7591 dynamic client registration on IAM, served at both POST /oauth/register and POST /oauth2/register.

  • Mirrors gitlab-rails DCR.

    • redirect_uris accepts a string or an array (the spec says array-only but rails wraps lone strings)
    • Autosets the client_name field with [Unverified Dynamic Application] as a default prefix
    • 200-character client_name limit
  • Major deviations from Rails:

    • A blank client_name is accepted and stored with the [Unverified Dynamic Application] prefix, where Rails rejects it.
    • The registration response omits require_pkce and dynamic, and keeps refresh_token in grant_types.
    • Redirect-URI errors return invalid_redirect_uri per RFC 7591 3.2.2, where Rails returns invalid_client_metadata for every metadata error.

Why?

Working off !473 (merged), this is the detailed version of the finished flow:

  1. An MCP client calls https://gitlab.com/api/v4/<mcp_path>, gets a 401 and a www-authenticate header response
returned response header
www-authenticate
Bearer realm="GitLab", scope="<mcp_path_scope>", resource_metadata="https://gitlab.com/.well-known/oauth-protected-resource/api/v4/<mcp_path>"

Fetches the PRM document using the provided resource_metadata GET https://gitlab.com/.well-known/oauth-protected-resource/api/v4/<mcp_path> (gitlab-rails, permanently).

json
{
  "resource": "https://gitlab.com/api/v4/<mcp_path>", # <mcp_path> = /mcp (default) or /orbit/mcp
  "authorization_servers": [
    "https://gitlab.com"
  ],
  "scopes_supported": [
    "<mcp_path_scope>" # mcp (default) or mcp_orbit
  ]
}
  1. It reads authorization_servers from that PRM (the pathless https://gitlab.com) and fetches the authorization server metadata from GET https://gitlab.com/.well-known/oauth-authorization-server (gitlab-rails today, IAM in !605 (merged) via GET http://gdk.test:8084/.well-known/oauth-authorization-server), learning the register, authorize and token URLs.
json
{
  "issuer": "http://gdk.test",
  "authorization_endpoint": "http://gdk.test/oauth/authorize",
  "token_endpoint": "http://gdk.test/oauth/token",
  "revocation_endpoint": "http://gdk.test/oauth/revoke",
  "introspection_endpoint": "http://gdk.test/oauth/introspect",
  "userinfo_endpoint": "http://gdk.test/oauth/userinfo",
  "jwks_uri": "http://gdk.test/oauth/discovery/keys",
  "registration_endpoint": "http://gdk.test/oauth/register",
  "scopes_supported": [
    "api",
    "read_api",
    "read_user",
    "create_runner",
    "manage_runner",
    "k8s_proxy",
    "self_rotate",
    "mcp",
    "mcp_orbit",
    "read_repository",
    "write_repository",
    "read_registry",
    "write_registry",
    "read_virtual_registry",
    "write_virtual_registry",
    "read_observability",
    "write_observability",
    "ai_features",
    "sudo",
    "admin_mode",
    "read_service_ping",
    "openid",
    "profile",
    "email",
    "ai_workflows",
    "user:*"
  ],
  "response_types_supported": [
    "code"
  ],
  "response_modes_supported": [
    "query",
    "fragment",
    "form_post"
  ],
  "grant_types_supported": [
    "authorization_code",
    "client_credentials",
    "device_code",
    "refresh_token"
  ],
  "token_endpoint_auth_methods_supported": [
    "client_secret_basic",
    "client_secret_post"
  ],
  "subject_types_supported": [
    "public"
  ],
  "id_token_signing_alg_values_supported": [
    "RS256"
  ],
  "claim_types_supported": [
    "normal"
  ],
  "claims_supported": [
    "iss",
    "sub",
    "aud",
    "exp",
    "iat",
    "sub_legacy",
    "name",
    "nickname",
    "preferred_username",
    "given_name",
    "family_name",
    "email",
    "email_verified",
    "website",
    "profile",
    "picture",
    "groups",
    "groups_direct",
    "https://gitlab.org/claims/groups/owner",
    "https://gitlab.org/claims/groups/maintainer",
    "https://gitlab.org/claims/groups/developer",
    "project_path",
    "ci_config_ref_uri",
    "ref_path",
    "sha",
    "environment",
    "jti"
  ],
  "code_challenge_methods_supported": [
    "S256",
    "plain"
  ]
}
  1. It POSTs to https://gitlab.com/oauth/register carrying "resource": "https://gitlab.com/api/v4/mcp". resolveMCPScope maps that URL to the single registered scope, mcp or mcp_orbit (this MR).
  2. It authorizes with S256 PKCE. A missing or plain challenge is rejected before the login redirect, and any requested scope is replaced with the registered one (!473 (merged)).
  3. The user signs in and consents on gitlab-rails, which reads the client from the consent challenge over gRPC (gitlab-rails, permanently).
  4. The exchange at https://gitlab.com/oauth/token returns an IAM JWT with the registered scope.
  5. The agent calls https://gitlab.com/api/v4/mcp with the access token and gitlab-rails checks the scope (resource server, permanently).

Screenshots & Recordings

Steps 1-4 Step 5: MCP Server Step 5: MCP Server (Orbit)
DCR_2_Steps_1-4 DCR_2_Step_5_MCP_Server_Orbit

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

Same as !473 (merged).

2. Register a dynamic client

  1. Register a client, store the generated id, and confirm the row:

    # case: register an mcp client
    curl -X POST http://gdk.test:8084/oauth/register \
      -d '{
        "client_name": "Local Test MCP Client",
        "redirect_uris": "http://localhost:1234/cb",
        "resource": "https://gdk.test:8080/api/v4/mcp"
      }'
    # expected output: {"client_id":"...","client_id_issued_at":...,"redirect_uris":["http://localhost:1234/cb"],"token_endpoint_auth_method":"none","grant_types":["authorization_code","refresh_token"],"client_name":"[Unverified Dynamic Application] Local Test MCP Client","scope":"mcp"}
    
    # store the client_id from that response, then confirm the stored row
    CLIENT_ID=""
    PGPASSWORD=iam-service psql -h localhost -p 5430 -U iam-service iam-service \
      -c "SELECT id, public, dynamic, owner, scopes FROM oauth_clients WHERE id = '$CLIENT_ID';"
    # expected output: one row with public=t, dynamic=t, empty owner, scopes {mcp}
  2. Bare minimum acceptable DCR request, redirect_uris is the only mandatory field, everything else defaults:

    # case: only redirect_uris
    curl -X POST http://gdk.test:8084/oauth/register \
      -d '{"redirect_uris": "http://localhost:1234/cb"}'
    # expected output: "token_endpoint_auth_method":"none","grant_types":["authorization_code","refresh_token"],"client_name":"[Unverified Dynamic Application]","scope":"mcp"
  3. The scope resolves from resource, else scope, else defaults to mcp:

    # case: an mcp resource registers the mcp scope
    curl -X POST http://gdk.test:8084/oauth/register \
      -d '{"client_name": "MCP Resource Client", "redirect_uris": "http://localhost:1234/cb", "resource": "https://gdk.test:8080/api/v4/mcp"}'
    # expected output: "scope":"mcp"
    
    # case: an orbit resource registers the mcp_orbit scope
    curl -X POST http://gdk.test:8084/oauth/register \
      -d '{"client_name": "Orbit Client", "redirect_uris": "http://localhost:1234/cb", "resource": "https://gdk.test:8080/api/v4/orbit/mcp"}'
    # expected output: "scope":"mcp_orbit"
    
    # case: a non-MCP scope with no resource falls back to mcp
    curl -X POST http://gdk.test:8084/oauth/register \
      -d '{"client_name": "Greedy Client", "redirect_uris": "http://localhost:1234/cb", "scope": "api read_repository"}'
    # expected output: "scope":"mcp"
    
    # case: an mcp_orbit scope with no resource resolves to mcp_orbit
    curl -X POST http://gdk.test:8084/oauth/register \
      -d '{"client_name": "Orbit Scope Client", "redirect_uris": "http://localhost:1234/cb", "scope": "mcp_orbit"}'
    # expected output: "scope":"mcp_orbit"
  4. redirect_uris is accepted as a string or an array (mirroring Rails), and validated:

    # case: a lone string is wrapped into an array
    curl -X POST http://gdk.test:8084/oauth/register \
      -d '{"client_name": "String Redirect", "redirect_uris": "http://localhost:1234/cb", "resource": "https://gdk.test:8080/api/v4/mcp"}'
    # expected output: "redirect_uris":["http://localhost:1234/cb"]
    
    # case: an array is accepted as-is
    curl -X POST http://gdk.test:8084/oauth/register \
      -d '{"client_name": "Array Redirect", "redirect_uris": ["http://localhost:1234/cb", "https://app.example.com/callback"], "resource": "https://gdk.test:8080/api/v4/mcp"}'
    # expected output: "redirect_uris":["http://localhost:1234/cb","https://app.example.com/callback"]
    
    # case: missing redirect_uris is rejected
    curl -X POST http://gdk.test:8084/oauth/register \
      -d '{"client_name": "No Redirects"}'
    # expected output: {"error":"invalid_redirect_uri","error_description":"Redirect URI can't be blank"}
    
    # case: null redirect_uris is rejected as blank
    curl -X POST http://gdk.test:8084/oauth/register \
      -d '{"client_name": "Null Redirect", "redirect_uris": null}'
    # expected output: {"error":"invalid_redirect_uri","error_description":"Redirect URI can't be blank"}
    
    # case: an empty string is rejected as blank
    curl -X POST http://gdk.test:8084/oauth/register \
      -d '{"client_name": "Empty Redirect", "redirect_uris": ""}'
    # expected output: {"error":"invalid_redirect_uri","error_description":"Redirect URI can't be blank"}
    
    # case: a malformed URL is rejected
    curl -X POST http://gdk.test:8084/oauth/register \
      -d '{"client_name": "Bad URI", "redirect_uris": "not-a-url"}'
    # expected output: {"error":"invalid_redirect_uri","error_description":"redirect_uris contains an invalid URI"}
    
    # case: a wrong-typed body is rejected with the generic metadata error
    # (RFC 7591 section 3.2.2 defines no code for a malformed body, so this generic 400 is compliant)
    curl -X POST http://gdk.test:8084/oauth/register \
      -d '{"client_name": "Wrong Type", "redirect_uris": 123}'
    # expected output: {"error":"invalid_client_metadata","error_description":"invalid request body"}
  5. client_name is optional, prefixed [Unverified Dynamic Application], capped at 200 characters:

    # case: an omitted client_name names the app with just the prefix
    curl -X POST http://gdk.test:8084/oauth/register \
      -d '{"redirect_uris": "http://localhost:1234/cb", "resource": "https://gdk.test:8080/api/v4/mcp"}'
    # expected output: "client_name":"[Unverified Dynamic Application]"
    
    # case: a 200-character client_name is rejected
    curl -X POST http://gdk.test:8084/oauth/register \
      -d "{\"client_name\": \"$(printf 'a%.0s' {1..200})\", \"redirect_uris\": \"http://localhost:1234/cb\"}"
    # expected output: {"error":"invalid_client_metadata","error_description":"client_name is too long"}

NB: a real DCR client has an empty owner, but the consent challenge validation on gitlab-rails still rejects a blank client_owner. Until gitlab-org/gitlab#623570 (closed) is closed, set one by hand:

PGPASSWORD=iam-service psql -h localhost -p 5430 -U iam-service iam-service \
  -c "UPDATE oauth_clients SET owner='root' WHERE id = '$CLIENT_ID';"

NB: Run the foreign key removal migration before step 4.

3. Validate the registered client under the !473 (merged) authz enforcement

  1. Generate and store the request constants and PKCE pair used by the validations below:

    # scope types
    NON_MCP_SCOPE="api"
    MCP_SCOPE="mcp"
    
    # code_challenge_method types
    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=$CLIENT_ID&redirect_uri=http://localhost:1234/cb&response_type=code&state=localteststate&scope=$SCOPE&code_challenge=$CHALLENGE&code_challenge_method=$PKCE"
    }
  2. Verify the !473 (merged) enforcement applies to the registered client:

    # Missing code_challenge
    SCOPE=$MCP_SCOPE CHALLENGE=$EMPTY_CHALLENGE PKCE=$S256_PKCE
    authorize
    # http://localhost:1234/cb?error=invalid_request&error_description=...S256...
    
    # A non-registered scope is forced to the registered one
    SCOPE=$NON_MCP_SCOPE CHALLENGE=$S256_CHALLENGE PKCE=$S256_PKCE
    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=...

    The full rejection matrix is covered in !473 (merged).

4. Obtain an IAM-issued JWT using authorization code flow with PKCE

  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=$CLIENT_ID&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=$CLIENT_ID&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=$CLIENT_ID&code=$CODE&redirect_uri=http://localhost:1234/cb"
    # 400 {"error":"invalid_request","error_description":"...The PKCE code verifier is required..."}

5. Use the token at the MCP API

  1. Call the GitLab MCP server with the mcp scoped token:

    ACCESS_TOKEN=""
    curl -X POST https://gdk.test:8080/api/v4/mcp \
      -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"local-test","version":"0.1"}}}'
    # 200 with serverInfo.name "Official GitLab MCP Server"
  2. For mcp_orbit, repeat steps 3 and 4 with the orbit client from step 2 and call the Knowledge Graph orbit endpoint. It is EE only, gated behind the knowledge_graph feature flag and an orbit license entitlement, so it returns 404/403 unless both are enabled for the user:

    curl -X POST https://gdk.test:8080/api/v4/orbit/mcp \
      -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"local-test","version":"0.1"}}}'
    # 200 with serverInfo.name "GitLab Orbit MCP Server"

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