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)
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/registerand POST /oauth2/register. -
Mirrors gitlab-rails DCR.
redirect_urisaccepts 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_namelimit
-
Major deviations from Rails:
- A blank
client_nameis accepted and stored with the[Unverified Dynamic Application]prefix, where Rails rejects it. - The registration response omits
require_pkceanddynamic, and keepsrefresh_tokeningrant_types. - Redirect-URI errors return
invalid_redirect_uriper RFC 7591 3.2.2, where Rails returnsinvalid_client_metadatafor every metadata error.
- A blank
Why?
Working off !473 (merged), this is the detailed version of the finished flow:
- An MCP client calls
https://gitlab.com/api/v4/<mcp_path>, gets a 401 and awww-authenticateheader 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
]
}- It reads
authorization_serversfrom that PRM (the pathlesshttps://gitlab.com) and fetches the authorization server metadata fromGET https://gitlab.com/.well-known/oauth-authorization-server(gitlab-rails today, IAM in !605 (merged) viaGET 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"
]
}- It POSTs to
https://gitlab.com/oauth/registercarrying"resource": "https://gitlab.com/api/v4/mcp". resolveMCPScope maps that URL to the single registered scope,mcpormcp_orbit(this MR). - It authorizes with S256 PKCE. A missing or
plainchallenge is rejected before the login redirect, and any requested scope is replaced with the registered one (!473 (merged)). - The user signs in and consents on gitlab-rails, which reads the client from the consent challenge over gRPC (gitlab-rails, permanently).
- The exchange at
https://gitlab.com/oauth/tokenreturns an IAM JWT with the registered scope. - The agent calls
https://gitlab.com/api/v4/mcpwith 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:8080and the IAM service ishttp://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
-
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} -
Bare minimum acceptable DCR request,
redirect_urisis 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" -
The scope resolves from
resource, elsescope, else defaults tomcp:# 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" -
redirect_urisis 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"} -
client_nameis 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
-
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" } -
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
-
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" -
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=$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 thecode_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
-
Call the GitLab MCP server with the
mcpscoped 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" -
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 theknowledge_graphfeature flag and anorbitlicense entitlement, so it returns404/403unless 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.