Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/dpop-client-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/core': minor
---

Add DPoP (RFC 9449 / SEP-1932) sender-constrained access token support to the client.

- Opt in by implementing `OAuthClientProvider.dpop()` returning a `DpopSession` (new, along with `generateDpopKeyPair`, `accessTokenHash`, `isDpopNonceChallenge`). `auth()` / `exchangeAuthorization` / `refreshAuthorization` / `fetchToken` then sign a DPoP proof into token requests (retrying once on an authorization-server `use_dpop_nonce` challenge, with client authentication re-applied per attempt), and `StreamableHTTPClientTransport`, `SSEClientTransport` and `withOAuth` present a `token_type: "DPoP"` access token as `Authorization: DPoP <token>` plus a fresh per-request proof, retry a resource-server `use_dpop_nonce` challenge once, and pick up a `DPoP-Nonce` delivered on any response. Tokens the AS issued as `Bearer` are still presented as Bearer.
- DPoP is applied at the fetch layer: the transports wrap their resource-server `fetch` (including a caller-supplied `fetch` / `eventSourceInit.fetch`) with the new `withDpopFromProvider(provider)` middleware, so proofs are always bound to the request actually sent. `withDpop(session, getToken)` is exported for callers that manage tokens themselves (e.g. alongside a minimal `AuthProvider`); the `AuthProvider` interface itself is unchanged.
- `auth()` now recovers from `invalid_dpop_proof` on refresh (e.g. a refresh token bound to a key that is no longer held) by discarding the tokens and re-authorizing, like `invalid_grant`. `OAuthErrorCode` gains `InvalidDpopProof` and `UseDpopNonce`; `extractWWWAuthenticateParams` recognizes the `DPoP` challenge scheme; `OAuthMetadataSchema` gains `dpop_signing_alg_values_supported`.
112 changes: 102 additions & 10 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ import {
import pkceChallenge from 'pkce-challenge';

import { AuthorizationServerMismatchError, InsecureTokenEndpointError, IssuerMismatchError, RegistrationRejectedError } from './authErrors';
import type { DpopSession } from './dpop';
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced in JSDoc {@linkcode}
import type { withDpopFromProvider, withOAuth } from './middleware';

// Re-exported for back-compat — the canonical home is ./authErrors.js.
export { AuthorizationServerMismatchError, InsecureTokenEndpointError, IssuerMismatchError, RegistrationRejectedError } from './authErrors';
Expand Down Expand Up @@ -206,6 +209,12 @@ export async function handleOAuthUnauthorized(
* not in scope. Providers that key storage on `ctx.issuer` MUST treat `ctx === undefined`
* as "return the most-recently-saved token set" (the only consumer is the resource server
* the token was minted for); providers that round-trip a single blob need no change.
*
* SEP-1932 (DPoP) note: DPoP request-signing is deliberately *not* done here. When the provider
* implements {@linkcode OAuthClientProvider.dpop | dpop()}, the transports wrap their
* resource-server `fetch` with {@linkcode withDpopFromProvider}, which upgrades the `Bearer` header
* this adapter produces to `DPoP` + proof for DPoP-bound tokens and handles nonce challenges — at
* the one layer that sees the real method, URL and response of every request.
*/
export function adaptOAuthProvider(
provider: OAuthClientProvider,
Expand Down Expand Up @@ -341,6 +350,28 @@ export interface OAuthClientProvider {
*/
addClientAuthentication?: AddClientAuthentication;

/**
* Enables DPoP (RFC 9449 / SEP-1932) sender-constrained tokens when implemented. When this
* resolves to a {@linkcode DpopSession}, {@linkcode auth} signs a DPoP proof into the token
* request, and the transports (and {@linkcode withOAuth}) present a resulting `token_type: DPoP`
* access token with the `DPoP` Authorization scheme plus a fresh per-request proof instead of
* `Bearer`, by wrapping their resource-server `fetch` with {@linkcode withDpopFromProvider}.
*
* Return the *same* session across calls — the AS/RS nonce state and signing key it holds are
* meant to persist for the life of this client registration. A minimal implementation:
* ```typescript
* class MyProvider implements OAuthClientProvider {
* private _dpop = DpopSession.create();
* dpop() { return this._dpop; }
* // ...
* }
* ```
*
* Left undefined (the default), the provider behaves exactly as before this option existed:
* plain Bearer tokens throughout.
*/
dpop?(): DpopSession | undefined | Promise<DpopSession | undefined>;

/**
* If defined, overrides the selection and validation of the
* RFC 8707 Resource Indicator. If left undefined, default
Expand Down Expand Up @@ -1025,7 +1056,11 @@ export async function auth(provider: OAuthClientProvider, options: AuthOptions):
await provider.invalidateCredentials?.('client');
await provider.invalidateCredentials?.('tokens');
return await authInternal(provider, options);
} else if (error.code === OAuthErrorCode.InvalidGrant) {
} else if (error.code === OAuthErrorCode.InvalidGrant || error.code === OAuthErrorCode.InvalidDpopProof) {
// invalid_dpop_proof on refresh typically means the stored refresh token is bound
// (RFC 9449 §5) to a DPoP key this process no longer holds — e.g. a non-extractable
// key regenerated across a restart. Like invalid_grant, the token set is unusable;
// drop it and fall through to a fresh authorization.
warnCredentialInvalidation(provider, error, 'tokens');
await provider.invalidateCredentials?.('tokens');
return await authInternal(provider, options);
Expand Down Expand Up @@ -1335,6 +1370,7 @@ async function authInternal(
refreshToken: tokens.refresh_token,
resource,
addClientAuthentication: provider.addClientAuthentication,
dpop: await provider.dpop?.(),
fetchFn
});
} catch (error) {
Expand Down Expand Up @@ -1449,9 +1485,17 @@ export async function selectResourceURL(
return new URL(resourceMetadata.resource);
}

/** Auth-scheme challenge tokens {@linkcode extractWWWAuthenticateParams} recognizes. */
const RECOGNIZED_CHALLENGE_SCHEMES = new Set(['bearer', 'dpop']);

/**
* Extract `resource_metadata`, `scope`, `error`, and `error_description` from a
* `WWW-Authenticate` header.
*
* Recognizes both the `Bearer` scheme (RFC 6750) and the `DPoP` scheme (RFC 9449 §7.1,
* SEP-1932) — a DPoP-protected resource's challenge carries the same parameters under `DPoP`
* instead of `Bearer`, and this must still surface `resource_metadata`/`scope` from it for
* discovery and SEP-2350 step-up to work against such a resource.
*/
export function extractWWWAuthenticateParams(res: Response): {
resourceMetadataUrl?: URL;
Expand All @@ -1465,7 +1509,7 @@ export function extractWWWAuthenticateParams(res: Response): {
}

const [type, scheme] = authenticateHeader.split(' ');
if (type?.toLowerCase() !== 'bearer' || !scheme) {
if (!type || !RECOGNIZED_CHALLENGE_SCHEMES.has(type.toLowerCase()) || !scheme) {
return {};
}

Expand Down Expand Up @@ -2096,13 +2140,21 @@ export async function executeTokenRequest(
clientInformation,
addClientAuthentication,
resource,
dpop,
fetchFn
}: {
metadata?: AuthorizationServerMetadata;
tokenRequestParams: URLSearchParams;
clientInformation?: OAuthClientInformationMixed;
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
resource?: URL;
/**
* SEP-1932 / RFC 9449 §5: when set, signs a DPoP proof into the token request's `DPoP`
* header — the prerequisite for obtaining a DPoP-bound access token. On a `400
* use_dpop_nonce` challenge (RFC 9449 §8) the request is retried exactly once with a
* fresh proof carrying the server-supplied nonce.
*/
dpop?: DpopSession;
fetchFn?: FetchLike;
}
): Promise<OAuthTokens> {
Expand All @@ -2117,19 +2169,50 @@ export async function executeTokenRequest(
tokenRequestParams.set('resource', resource.href);
}

if (addClientAuthentication) {
await addClientAuthentication(headers, tokenRequestParams, tokenUrl, metadata);
} else if (clientInformation) {
if (!addClientAuthentication && clientInformation) {
const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? [];
const authMethod = selectClientAuthMethod(clientInformation, supportedMethods);
applyClientAuthentication(authMethod, clientInformation as OAuthClientInformation, headers, tokenRequestParams);
}

const response = await (fetchFn ?? fetch)(tokenUrl, {
method: 'POST',
headers,
body: tokenRequestParams
});
const requestOnce = async (): Promise<Response> => {
const requestHeaders = new Headers(headers);
// Per attempt, not once up front: a `private_key_jwt` client_assertion carries a one-time
// `jti` (RFC 7521 §5.2), so the DPoP nonce retry below must mint a fresh one, not replay it.
if (addClientAuthentication) {
await addClientAuthentication(requestHeaders, tokenRequestParams, tokenUrl, metadata);
}
if (dpop) {
// No `ath`: RFC 9449 §4.3 step 12a only binds a proof to an access token when one is
// presented, and the token request is presenting credentials to *obtain* one.
requestHeaders.set('DPoP', await dpop.buildProof({ htm: 'POST', htu: tokenUrl }));
}
return (fetchFn ?? fetch)(tokenUrl, {
method: 'POST',
headers: requestHeaders,
body: tokenRequestParams
});
};

let response = await requestOnce();

// RFC 9449 §8: the AS may answer with `400 { error: "use_dpop_nonce" }` + `DPoP-Nonce`; a
// conformant client retries the token request once with a fresh proof carrying that nonce
// (buildProof picks it up automatically via the session's remembered nonce for this origin).
// Peek the body via a clone so a non-nonce 400 still flows into parseErrorResponse below with
// an unconsumed body.
if (dpop && response.status === 400) {
const challenge = (await response
.clone()
.json()
.catch(() => {})) as { error?: string } | undefined;
if (challenge?.error === OAuthErrorCode.UseDpopNonce) {
dpop.observeNonce(response, tokenUrl);
response = await requestOnce();
}
}
// RFC 9449 §8.2: newest-wins nonce capture applies to any response, success included.
dpop?.observeNonce(response, tokenUrl);

if (!response.ok) {
throw await parseErrorResponse(response);
Expand Down Expand Up @@ -2172,6 +2255,7 @@ export async function exchangeAuthorization(
redirectUri,
resource,
addClientAuthentication,
dpop,
fetchFn
}: {
metadata?: AuthorizationServerMetadata;
Expand All @@ -2187,6 +2271,8 @@ export async function exchangeAuthorization(
redirectUri: string | URL;
resource?: URL;
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
/** SEP-1932 / RFC 9449: see {@linkcode executeTokenRequest}'s `dpop` option. */
dpop?: DpopSession;
fetchFn?: FetchLike;
}
): Promise<OAuthTokens> {
Expand All @@ -2204,6 +2290,7 @@ export async function exchangeAuthorization(
clientInformation,
addClientAuthentication,
resource,
dpop,
fetchFn
});
}
Expand All @@ -2228,13 +2315,16 @@ export async function refreshAuthorization(
refreshToken,
resource,
addClientAuthentication,
dpop,
fetchFn
}: {
metadata?: AuthorizationServerMetadata;
clientInformation: OAuthClientInformationMixed;
refreshToken: string;
resource?: URL;
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
/** SEP-1932 / RFC 9449: see {@linkcode executeTokenRequest}'s `dpop` option. */
dpop?: DpopSession;
fetchFn?: FetchLike;
}
): Promise<OAuthTokens> {
Expand All @@ -2249,6 +2339,7 @@ export async function refreshAuthorization(
clientInformation,
addClientAuthentication,
resource,
dpop,
fetchFn
});

Expand Down Expand Up @@ -2346,6 +2437,7 @@ export async function fetchToken(
clientInformation: clientInformation ?? undefined,
addClientAuthentication: provider.addClientAuthentication,
resource,
dpop: await provider.dpop?.(),
fetchFn
});
}
Expand Down
Loading
Loading