Skip to content

Commit 28b0945

Browse files
author
Olivier Gintrand
committed
feat(oauth): support client_secret_basic token endpoint auth method (RFC 6749)
Signed-off-by: Olivier Gintrand <olivier.gintrand@forterro.com>
1 parent bc88d59 commit 28b0945

4 files changed

Lines changed: 98 additions & 13 deletions

File tree

mcpgateway/admin.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12136,6 +12136,11 @@ async def admin_add_gateway(request: Request, db: Session = Depends(get_db), use
1213612136
if scopes:
1213712137
oauth_config["scopes"] = scopes
1213812138

12139+
# Token endpoint auth method (RFC 6749 Section 2.3)
12140+
oauth_token_endpoint_auth_method = str(form.get("oauth_token_endpoint_auth_method", ""))
12141+
if oauth_token_endpoint_auth_method:
12142+
oauth_config["token_endpoint_auth_method"] = oauth_token_endpoint_auth_method
12143+
1213912144
LOGGER.info(f"✅ Assembled OAuth config from UI form fields: grant_type={oauth_grant_type}, issuer={oauth_issuer}")
1214012145
LOGGER.info(f"DEBUG: Complete oauth_config = {oauth_config}")
1214112146

@@ -12408,6 +12413,11 @@ async def admin_edit_gateway(
1240812413
if scopes:
1240912414
oauth_config["scopes"] = scopes
1241012415

12416+
# Token endpoint auth method (RFC 6749 Section 2.3)
12417+
oauth_token_endpoint_auth_method = str(form.get("oauth_token_endpoint_auth_method", ""))
12418+
if oauth_token_endpoint_auth_method:
12419+
oauth_config["token_endpoint_auth_method"] = oauth_token_endpoint_auth_method
12420+
1241112421
LOGGER.info(f"✅ Assembled OAuth config from UI form fields (edit): grant_type={oauth_grant_type}, issuer={oauth_issuer}")
1241212422

1241312423
user_email = get_user_email(user)

mcpgateway/admin_ui/gateways.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,9 @@ export const editGateway = async function (gatewayId) {
400400
const oauthRedirectUriField = safeGetElement("oauth-redirect-uri-gw-edit");
401401
const oauthIssuerField = safeGetElement("oauth-issuer-gw-edit");
402402
const oauthScopesField = safeGetElement("oauth-scopes-gw-edit");
403+
const oauthTokenEndpointAuthMethodField = safeGetElement(
404+
"oauth-token-endpoint-auth-method-gw-edit"
405+
);
403406
const oauthAuthCodeFields = safeGetElement(
404407
"oauth-auth-code-fields-gw-edit"
405408
);
@@ -526,6 +529,13 @@ export const editGateway = async function (gatewayId) {
526529
? config.scopes.join(" ")
527530
: "";
528531
}
532+
if (
533+
oauthTokenEndpointAuthMethodField &&
534+
config.token_endpoint_auth_method
535+
) {
536+
oauthTokenEndpointAuthMethodField.value =
537+
config.token_endpoint_auth_method;
538+
}
529539
}
530540
break;
531541
case "query_param":

mcpgateway/services/oauth_manager.py

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ async def _prepare_runtime_credentials(credentials: Dict[str, Any], flow_name: s
241241
logger.warning("Failed to prepare runtime OAuth credentials for %s flow: %s", flow_name, exc)
242242
return credentials
243243

244-
async def _post_token_request(self, url: str, data: Any, ca_certificate: Optional[str] = None, client_cert: Optional[str] = None, client_key: Optional[str] = None) -> httpx.Response:
244+
async def _post_token_request(self, url: str, data: Any, ca_certificate: Optional[str] = None, client_cert: Optional[str] = None, client_key: Optional[str] = None, headers: Optional[dict] = None) -> httpx.Response:
245245
"""POST to a token endpoint, using a custom SSL context when CA certs are provided.
246246
247247
When ``ca_certificate`` is supplied, an isolated ``httpx.AsyncClient``
@@ -256,16 +256,17 @@ async def _post_token_request(self, url: str, data: Any, ca_certificate: Optiona
256256
ca_certificate: Optional PEM-encoded CA certificate.
257257
client_cert: Optional client certificate for mTLS.
258258
client_key: Optional client private key for mTLS.
259+
headers: Optional extra headers (e.g. Authorization: Basic for client_secret_basic).
259260
260261
Returns:
261262
The HTTP response from the token endpoint.
262263
"""
263264
if ca_certificate:
264265
ssl_context = get_cached_ssl_context(ca_certificate, client_cert=client_cert, client_key=client_key)
265266
async with httpx.AsyncClient(verify=ssl_context) as client:
266-
return await client.post(url, data=data, timeout=self.request_timeout)
267+
return await client.post(url, data=data, headers=headers, timeout=self.request_timeout)
267268
client = await self._get_client()
268-
return await client.post(url, data=data, timeout=self.request_timeout)
269+
return await client.post(url, data=data, headers=headers, timeout=self.request_timeout)
269270

270271
# Keys whose values must never be echoed in error messages or logs.
271272
_SENSITIVE_TOKEN_KEYS = frozenset({"access_token", "refresh_token", "id_token", "client_secret", "password"})
@@ -1408,17 +1409,31 @@ async def _exchange_code_for_tokens(
14081409
token_url = runtime_credentials["token_url"]
14091410
redirect_uri = runtime_credentials["redirect_uri"]
14101411

1412+
# Determine token endpoint authentication method (RFC 6749 Section 2.3)
1413+
# - "client_secret_post" (default): client_id and client_secret in POST body
1414+
# - "client_secret_basic": credentials in Authorization: Basic header
1415+
token_auth_method = credentials.get("token_endpoint_auth_method", "client_secret_post")
1416+
use_basic_auth = token_auth_method == "client_secret_basic" and client_secret
1417+
1418+
# Build HTTP Basic Auth header if required by the provider
1419+
auth_header = None
1420+
if use_basic_auth:
1421+
basic_credentials = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
1422+
auth_header = {"Authorization": f"Basic {basic_credentials}"}
1423+
14111424
# Prepare token exchange data
14121425
token_data = {
14131426
"grant_type": "authorization_code",
14141427
"code": code,
14151428
"redirect_uri": redirect_uri,
1416-
"client_id": client_id,
14171429
}
14181430

1419-
# Only include client_secret if present (public clients don't have secrets)
1420-
if client_secret:
1421-
token_data["client_secret"] = client_secret
1431+
# Include client credentials in POST body only when not using Basic auth
1432+
if not use_basic_auth:
1433+
token_data["client_id"] = client_id
1434+
# Only include client_secret if present (public clients don't have secrets)
1435+
if client_secret:
1436+
token_data["client_secret"] = client_secret
14221437

14231438
# Add PKCE code_verifier if present (RFC 7636)
14241439
if code_verifier:
@@ -1442,7 +1457,7 @@ async def _exchange_code_for_tokens(
14421457
# Exchange code for token with retries
14431458
for attempt in range(self.max_retries):
14441459
try:
1445-
response = await self._post_token_request(token_url, token_data, ca_certificate=ca_certificate, client_cert=client_cert, client_key=client_key)
1460+
response = await self._post_token_request(token_url, token_data, ca_certificate=ca_certificate, client_cert=client_cert, client_key=client_key, headers=auth_header)
14461461
response.raise_for_status()
14471462

14481463
token_response = self._parse_token_response(response)
@@ -1502,16 +1517,28 @@ async def refresh_token(
15021517
if not client_id:
15031518
raise OAuthError("No client_id configured for OAuth provider")
15041519

1520+
# Determine token endpoint authentication method (RFC 6749 Section 2.3)
1521+
token_auth_method = credentials.get("token_endpoint_auth_method", "client_secret_post")
1522+
use_basic_auth = token_auth_method == "client_secret_basic" and client_secret
1523+
1524+
# Build HTTP Basic Auth header if required by the provider
1525+
auth_header = None
1526+
if use_basic_auth:
1527+
basic_credentials = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
1528+
auth_header = {"Authorization": f"Basic {basic_credentials}"}
1529+
15051530
# Prepare token refresh request
15061531
token_data = {
15071532
"grant_type": "refresh_token",
15081533
"refresh_token": refresh_token,
1509-
"client_id": client_id,
15101534
}
15111535

1512-
# Add client_secret if available (some providers require it)
1513-
if client_secret:
1514-
token_data["client_secret"] = client_secret
1536+
# Include client credentials in POST body only when not using Basic auth
1537+
if not use_basic_auth:
1538+
token_data["client_id"] = client_id
1539+
# Add client_secret if available (some providers require it)
1540+
if client_secret:
1541+
token_data["client_secret"] = client_secret
15151542

15161543
# Add resource parameter for JWT access token (RFC 8707)
15171544
# Must be included in refresh requests to maintain JWT token type
@@ -1531,7 +1558,7 @@ async def refresh_token(
15311558
# Attempt token refresh with retries
15321559
for attempt in range(self.max_retries):
15331560
try:
1534-
response = await self._post_token_request(token_url, token_data, ca_certificate=ca_certificate, client_cert=client_cert, client_key=client_key)
1561+
response = await self._post_token_request(token_url, token_data, ca_certificate=ca_certificate, client_cert=client_cert, client_key=client_key, headers=auth_header)
15351562
if response.status_code == 200:
15361563
token_response = self._parse_token_response(response)
15371564

mcpgateway/templates/admin.html

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5956,6 +5956,25 @@ <h3 class="text-lg font-bold mb-4 dark:text-gray-200">
59565956
read:user")
59575957
</p>
59585958
</div>
5959+
5960+
<div>
5961+
<label
5962+
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
5963+
>
5964+
Token Endpoint Auth Method
5965+
</label>
5966+
<select
5967+
name="oauth_token_endpoint_auth_method"
5968+
id="oauth-token-endpoint-auth-method-gw"
5969+
class="mt-1 px-1.5 block w-full rounded-md border border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:text-gray-300"
5970+
>
5971+
<option value="client_secret_post">client_secret_post (credentials in POST body)</option>
5972+
<option value="client_secret_basic">client_secret_basic (HTTP Basic Auth header)</option>
5973+
</select>
5974+
<p class="mt-1 text-sm text-gray-500">
5975+
How client credentials are sent to the token endpoint (RFC 6749 Section 2.3)
5976+
</p>
5977+
</div>
59595978
</div>
59605979
</div>
59615980

@@ -10343,6 +10362,25 @@ <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
1034310362
read:user")
1034410363
</p>
1034510364
</div>
10365+
10366+
<div>
10367+
<label
10368+
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
10369+
>
10370+
Token Endpoint Auth Method
10371+
</label>
10372+
<select
10373+
name="oauth_token_endpoint_auth_method"
10374+
id="oauth-token-endpoint-auth-method-gw-edit"
10375+
class="mt-1 px-1.5 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:text-gray-300"
10376+
>
10377+
<option value="client_secret_post">client_secret_post (credentials in POST body)</option>
10378+
<option value="client_secret_basic">client_secret_basic (HTTP Basic Auth header)</option>
10379+
</select>
10380+
<p class="mt-1 text-sm text-gray-500">
10381+
How client credentials are sent to the token endpoint (RFC 6749 Section 2.3)
10382+
</p>
10383+
</div>
1034610384
</div>
1034710385
</div>
1034810386
</div>

0 commit comments

Comments
 (0)