Skip to content

Commit 5156ed7

Browse files
feat(client): SEP-837/2207 — application_type heuristic, grant_types default, https token-endpoint guard
Claude-Session: https://claude.ai/code/session_01XBib5gRe8AMPPJhySCz3EJ
1 parent c843d7e commit 5156ed7

16 files changed

Lines changed: 773 additions & 77 deletions

File tree

.changeset/auth-dcr-hygiene.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@modelcontextprotocol/client': minor
3+
'@modelcontextprotocol/core': minor
4+
---
5+
6+
Dynamic Client Registration hygiene for the 2026-07-28 authorization requirements (SEP-837, SEP-2207). New `resolveClientMetadata(provider)` reads `provider.clientMetadata` and applies the spec defaults — `application_type` derived from the redirect URIs (loopback or custom scheme → `'native'`, otherwise `'web'`), `grant_types: ['authorization_code', 'refresh_token']` when omitted — and `auth()` calls it once so DCR and scope selection see the same document; consumer-set values are never overwritten. DCR rejection now throws the new `RegistrationRejectedError` carrying the HTTP status, raw body, and submitted metadata — **breaking for direct `registerClient()` callers**: rejection no longer throws `OAuthError`, so update `instanceof` checks. `OAuthClientMetadata` gains a typed `application_type?: string` field (expected `'native'` / `'web'`; tolerant on parse). `OAuthErrorCode` adds `InvalidRedirectUri`. The token-exchange and refresh paths now throw the new `InsecureTokenEndpointError` for a non-`https:` token endpoint (`localhost` / `127.0.0.1` / `::1` exempt), and `auth()` surfaces it on the refresh branch instead of silently re-authorizing.

docs/migration-SKILL.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,14 @@ Individual OAuth error classes replaced with single `OAuthError` class and `OAut
213213

214214
Removed: `OAUTH_ERRORS` constant.
215215

216+
The OAuth client flow additionally throws dedicated classes from `@modelcontextprotocol/client` (all extend `OAuthClientFlowError`, **not** `OAuthError``auth()`'s `OAuthError` retry path will not catch them):
217+
218+
| Throw site | v2 class |
219+
| -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
220+
| `registerClient()` rejected by AS (any RFC 7591 error incl. `invalid_client_metadata`, `invalid_redirect_uri`) | `RegistrationRejectedError` (`status`, `body`, `submittedMetadata`) |
221+
| `exchangeAuthorization()` / `refreshAuthorization()` / `fetchToken()` non-https token endpoint | `InsecureTokenEndpointError` (`tokenEndpoint`) |
222+
| RFC 9207 `iss` mismatch / RFC 8414 §3.3 issuer-echo mismatch | `IssuerMismatchError` (`kind`, `expected`, `received`) |
223+
216224
Update OAuth error handling:
217225

218226
```typescript
@@ -580,6 +588,10 @@ OAuth callback handling: pass the callback URL's `URLSearchParams` to `transport
580588

581589
`discoverAuthorizationServerMetadata()` now rejects metadata whose `issuer` does not exactly match the URL it was fetched for (RFC 8414 §3.3), throwing `IssuerMismatchError`. Pass `skipIssuerMetadataValidation: true` on `AuthOptions` (or `skipIssuerValidation: true` on the helper) only as a temporary workaround for a known-misconfigured AS.
582590

591+
`auth()` reads `provider.clientMetadata` once via `resolveClientMetadata()` and applies SEP-837/SEP-2207 defaults to the DCR body: `grant_types` defaults to `['authorization_code', 'refresh_token']`; `application_type` is derived from `redirect_uris` (loopback / custom URI scheme → `'native'`, otherwise `'web'`). A field you set explicitly is never overwritten — set `clientMetadata.application_type` / `clientMetadata.grant_types` to override. Direct `registerClient()` callers wanting the same defaults pass `resolveClientMetadata(provider)` as `clientMetadata`.
592+
593+
Token-exchange / refresh now refuse to send credentials to a non-`https:` token endpoint (loopback `localhost` / `127.0.0.1` / `::1` exempt), throwing `InsecureTokenEndpointError` with no opt-out. `auth()` surfaces this on every path including refresh — switch any plain-`http:` AS on a non-loopback host to TLS.
594+
583595
No code changes required; wire-behavior note: on a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request (caller `signal` / timeout) closes that request's SSE response stream as the spec cancellation signal — `notifications/cancelled` is no longer POSTed
584596
there. 2025-era connections and stdio at any era still send `notifications/cancelled`. Custom `Transport` implementations that open one underlying request per outbound message and honor `TransportSendOptions.requestSignal` may declare `readonly hasPerRequestStream = true` to opt
585597
into the same routing.

docs/migration.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,33 @@ try {
930930
}
931931
```
932932

933+
### Dynamic Client Registration: `application_type` and `grant_types` defaults (SEP-837, SEP-2207)
934+
935+
`OAuthClientMetadata` now has a typed `application_type?: string` field (expected `'native'` / `'web'`; tolerant on parse). `auth()` resolves your provider's `clientMetadata` once via the new `resolveClientMetadata()` and uses that resolved document for both Dynamic Client Registration and scope selection. When `application_type` is unset, it is derived from your `redirect_uris`: a loopback host (`localhost` / `127.0.0.1` / `[::1]`) or a custom URI scheme yields `'native'`; anything else yields `'web'`. Set it explicitly when the heuristic is wrong for your deployment (for example a web app dev-served on `localhost`); a value you set is never overwritten.
936+
937+
`resolveClientMetadata()` also defaults `grant_types` to `['authorization_code', 'refresh_token']` when you omit it, so authorization servers that gate refresh-token issuance on the registered grant types issue one. If you set `grant_types` explicitly, include `'refresh_token'` yourself if you want refresh tokens. CIMD users author the hosted metadata document themselves and should include `refresh_token` there. Direct callers of `registerClient()` that want the same defaults should pass `resolveClientMetadata(provider)` as `clientMetadata`.
938+
939+
DCR rejection now throws `RegistrationRejectedError` (carrying `status`, `body`, and `submittedMetadata`) instead of a generic `OAuthError`. Catch it to inspect the AS's `error` / `error_description` and retry with adjusted metadata, or surface a meaningful error.
940+
941+
```typescript
942+
import { registerClient, RegistrationRejectedError } from '@modelcontextprotocol/client';
943+
944+
try {
945+
await registerClient(authorizationServerUrl, { metadata, clientMetadata });
946+
} catch (e) {
947+
if (e instanceof RegistrationRejectedError) {
948+
// e.submittedMetadata is exactly what was POSTed (after SDK defaults applied)
949+
// e.body is the raw RFC 7591 error JSON from the AS
950+
}
951+
}
952+
```
953+
954+
### Token endpoint must use TLS (SEP-2207)
955+
956+
`exchangeAuthorization()`, `refreshAuthorization()`, and `fetchToken()` now throw `InsecureTokenEndpointError` when the resolved token endpoint is not `https:`. Only `localhost`, `127.0.0.1`, and `::1` are exempt for local development. `auth()` surfaces this error on every path (including the refresh branch) rather than silently re-authorizing. If you were pointing at a plain-`http:` authorization server on a non-loopback host — including cluster-DNS names like `http://oauth.svc.cluster.local` or private addresses like `http://10.0.0.5` — switch it to TLS; there is no opt-out.
957+
958+
**Storage confidentiality remains yours.** `OAuthClientProvider.saveTokens()` receives the raw `refresh_token`; store it in platform-appropriate secure storage. The SDK guarantees transit confidentiality but cannot secure your storage layer.
959+
933960
### Experimental tasks interception removed
934961

935962
The 2025-11 experimental tasks side-channel woven through `Protocol` has been removed in preparation for the SEP-2663 Tasks Extension. The following are gone with no in-place replacement:

examples/oauth/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ runClient('oauth', async () => {
9494
redirect_uris: [CALLBACK_URL],
9595
grant_types: ['authorization_code', 'refresh_token'],
9696
response_types: ['code'],
97+
application_type: 'native',
9798
token_endpoint_auth_method: 'client_secret_post'
9899
};
99100
const provider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, url => {

examples/oauth/simpleOAuthClient.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ class InteractiveOAuthClient {
174174
redirect_uris: [CALLBACK_URL],
175175
grant_types: ['authorization_code', 'refresh_token'],
176176
response_types: ['code'],
177+
application_type: 'native',
177178
token_endpoint_auth_method: 'client_secret_post'
178179
};
179180

packages/client/src/client/auth.ts

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ import {
2525
} from '@modelcontextprotocol/core';
2626
import pkceChallenge from 'pkce-challenge';
2727

28-
import { IssuerMismatchError } from './authErrors.js';
28+
import { InsecureTokenEndpointError, IssuerMismatchError, RegistrationRejectedError } from './authErrors.js';
2929

3030
// Re-exported for back-compat — the canonical home is ./authErrors.js.
31-
export { IssuerMismatchError } from './authErrors.js';
31+
export { InsecureTokenEndpointError, IssuerMismatchError, RegistrationRejectedError } from './authErrors.js';
3232

3333
/**
3434
* Function type for adding client authentication to token requests.
@@ -609,6 +609,64 @@ export function applyPublicAuth(clientId: string, params: URLSearchParams): void
609609
params.set('client_id', clientId);
610610
}
611611

612+
/** Loopback hosts exempt from the in-transit `https:` requirement (RFC 8252 §7.3). */
613+
function isLoopbackHost(hostname: string): boolean {
614+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
615+
}
616+
617+
/**
618+
* Derives an OIDC `application_type` from a client's registered redirect URIs
619+
* when the consumer has not set one explicitly (SEP-837). Loopback hosts and
620+
* non-`http(s)` custom URI schemes indicate a native application (RFC 8252);
621+
* everything else is treated as a web application. The result is a heuristic
622+
* default — callers that know better should set `clientMetadata.application_type`
623+
* themselves, which {@linkcode resolveClientMetadata} never overwrites.
624+
*
625+
* A mixed redirect set (for example a public `https:` URI alongside a loopback
626+
* URI) is inherently ambiguous under OIDC DCR §2 — neither value satisfies the
627+
* AS for both URIs — so consumers with mixed sets should set `application_type`
628+
* explicitly rather than relying on this heuristic.
629+
*/
630+
function deriveApplicationType(redirectUris: readonly string[] | undefined): 'native' | 'web' {
631+
for (const raw of redirectUris ?? []) {
632+
let url: URL;
633+
try {
634+
url = new URL(raw);
635+
} catch {
636+
continue;
637+
}
638+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'native';
639+
if (isLoopbackHost(url.hostname)) return 'native';
640+
}
641+
return 'web';
642+
}
643+
644+
/**
645+
* Reads {@linkcode OAuthClientProvider.clientMetadata | clientMetadata} from the
646+
* provider and fills the SEP-837 / SEP-2207 defaults the SDK relies on, so every
647+
* downstream consumer ({@linkcode registerClient}, {@linkcode determineScope},
648+
* the CIMD path) sees a consistent, fully-populated document.
649+
*
650+
* - `grant_types` defaults to `['authorization_code', 'refresh_token']` so
651+
* authorization servers that gate refresh-token issuance on the registered
652+
* grant types issue one (SEP-2207).
653+
* - `application_type` defaults from `redirect_uris`: loopback redirect hosts
654+
* and custom URI schemes → `'native'`, otherwise `'web'` (SEP-837 / RFC 8252).
655+
*
656+
* A field the consumer set explicitly is **never** overwritten. {@linkcode auth}
657+
* calls this once at the top of the flow; direct callers of
658+
* {@linkcode registerClient} that want the same defaults should pass the result
659+
* of this function as `clientMetadata`.
660+
*/
661+
export function resolveClientMetadata(provider: Pick<OAuthClientProvider, 'clientMetadata'>): OAuthClientMetadata {
662+
const clientMetadata = provider.clientMetadata;
663+
return {
664+
...clientMetadata,
665+
grant_types: clientMetadata.grant_types ?? ['authorization_code', 'refresh_token'],
666+
application_type: clientMetadata.application_type ?? deriveApplicationType(clientMetadata.redirect_uris)
667+
};
668+
}
669+
612670
/**
613671
* Parses an OAuth error response from a string or Response object.
614672
*
@@ -716,7 +774,8 @@ export function determineScope(options: {
716774
let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope;
717775

718776
// SEP-2207: Append offline_access when the AS advertises it
719-
// and the client supports the refresh_token grant.
777+
// and the client supports the refresh_token grant. `clientMetadata` is the
778+
// resolveClientMetadata() result, so `grant_types` is always populated.
720779
if (
721780
effectiveScope &&
722781
authServerMetadata?.scopes_supported?.includes('offline_access') &&
@@ -733,6 +792,10 @@ async function authInternal(
733792
provider: OAuthClientProvider,
734793
{ serverUrl, authorizationCode, iss, scope, resourceMetadataUrl, fetchFn, skipIssuerMetadataValidation }: AuthOptions
735794
): Promise<AuthResult> {
795+
// SEP-837 / SEP-2207: read provider.clientMetadata once and apply spec
796+
// defaults so every downstream consumer sees the same document.
797+
const clientMetadata = resolveClientMetadata(provider);
798+
736799
// Check if the provider has cached discovery state to skip discovery
737800
const cachedState = await provider.discoveryState?.();
738801

@@ -823,7 +886,7 @@ async function authInternal(
823886
requestedScope: scope,
824887
resourceMetadata,
825888
authServerMetadata: metadata,
826-
clientMetadata: provider.clientMetadata
889+
clientMetadata
827890
});
828891

829892
// Handle client registration if needed
@@ -859,7 +922,7 @@ async function authInternal(
859922

860923
const fullInformation = await registerClient(authorizationServerUrl, {
861924
metadata,
862-
clientMetadata: provider.clientMetadata,
925+
clientMetadata,
863926
scope: resolvedScope,
864927
fetchFn
865928
});
@@ -916,6 +979,12 @@ async function authInternal(
916979
await provider.saveTokens(newTokens);
917980
return 'AUTHORIZED';
918981
} catch (error) {
982+
// A non-TLS token endpoint is a configuration error — re-authorizing cannot
983+
// fix it. Surface it so the consumer sees the misconfiguration instead of an
984+
// unexplained re-auth prompt.
985+
if (error instanceof InsecureTokenEndpointError) {
986+
throw error;
987+
}
919988
// If this is a ServerError, or an unknown type, log it out and try to continue. Otherwise, escalate so we can fix things and retry.
920989
if (!(error instanceof OAuthError) || error.code === OAuthErrorCode.ServerError) {
921990
// Could not refresh OAuth tokens
@@ -1655,6 +1724,13 @@ export async function executeTokenRequest(
16551724
): Promise<OAuthTokens> {
16561725
const tokenUrl = metadata?.token_endpoint ? new URL(metadata.token_endpoint) : new URL('/token', authorizationServerUrl);
16571726

1727+
// SEP-2207: refresh tokens (and authorization codes) MUST be kept confidential
1728+
// in transit. Refuse to send them to a non-TLS token endpoint. Loopback hosts
1729+
// are exempt so local development and the test harness keep working.
1730+
if (tokenUrl.protocol !== 'https:' && !isLoopbackHost(tokenUrl.hostname)) {
1731+
throw new InsecureTokenEndpointError(tokenUrl.href);
1732+
}
1733+
16581734
const headers = new Headers({
16591735
'Content-Type': 'application/x-www-form-urlencoded',
16601736
Accept: 'application/json'
@@ -1937,19 +2013,24 @@ export async function registerClient(
19372013
registrationUrl = new URL('/register', authorizationServerUrl);
19382014
}
19392015

2016+
// `clientMetadata` arrives via resolveClientMetadata() inside auth(), so the
2017+
// SEP-837/2207 defaults are already applied. Direct callers that want the
2018+
// same defaults should pass resolveClientMetadata(provider) here.
2019+
const submittedMetadata: OAuthClientMetadata = {
2020+
...clientMetadata,
2021+
...(scope === undefined ? {} : { scope })
2022+
};
2023+
19402024
const response = await (fetchFn ?? fetch)(registrationUrl, {
19412025
method: 'POST',
19422026
headers: {
19432027
'Content-Type': 'application/json'
19442028
},
1945-
body: JSON.stringify({
1946-
...clientMetadata,
1947-
...(scope === undefined ? {} : { scope })
1948-
})
2029+
body: JSON.stringify(submittedMetadata)
19492030
});
19502031

19512032
if (!response.ok) {
1952-
throw await parseErrorResponse(response);
2033+
throw new RegistrationRejectedError({ status: response.status, body: await response.text(), submittedMetadata });
19532034
}
19542035

19552036
return OAuthClientInformationFullSchema.parse(await response.json());

packages/client/src/client/authErrors.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
* the failure mode without string-matching messages.
77
*/
88

9+
import type { OAuthClientMetadata } from '@modelcontextprotocol/core';
10+
911
/**
1012
* Base class for errors thrown by the OAuth client flow. All flow-specific
1113
* error classes in this module extend it so callers can catch the family with
@@ -54,3 +56,51 @@ export class IssuerMismatchError extends OAuthClientFlowError {
5456
this.received = received;
5557
}
5658
}
59+
60+
/**
61+
* Thrown by `registerClient()` when the authorization server rejects a
62+
* Dynamic Client Registration request. Carries the HTTP status, the raw
63+
* response body, and the metadata that was submitted, so callers can inspect
64+
* the AS's `error` / `error_description` and retry with adjusted metadata
65+
* (for example a different `application_type`) per SEP-837.
66+
*
67+
* Intentionally does **not** extend `OAuthError`: registration rejection is
68+
* not a recoverable-by-credential-invalidation condition, and staying outside
69+
* that hierarchy keeps it from being caught by `auth()`'s `OAuthError` retry
70+
* path.
71+
*/
72+
export class RegistrationRejectedError extends OAuthClientFlowError {
73+
/** HTTP status code returned by the registration endpoint. */
74+
public readonly status: number;
75+
/** Raw response body text (typically an RFC 7591 error JSON document). */
76+
public readonly body: string;
77+
/** The exact client metadata that was POSTed (after SDK defaults were applied). */
78+
public readonly submittedMetadata: OAuthClientMetadata;
79+
80+
constructor(args: { status: number; body: string; submittedMetadata: OAuthClientMetadata }) {
81+
super(`Dynamic Client Registration rejected (HTTP ${args.status}): ${args.body}`);
82+
this.status = args.status;
83+
this.body = args.body;
84+
this.submittedMetadata = args.submittedMetadata;
85+
}
86+
}
87+
88+
/**
89+
* Thrown by the token-exchange and refresh paths when the resolved token
90+
* endpoint is not `https:` and is not a loopback host (SEP-2207). This is a
91+
* configuration error — re-authorizing cannot fix it — so it intentionally does
92+
* **not** extend `OAuthError` and `auth()`'s refresh branch rethrows it instead
93+
* of falling through to a fresh `/authorize` redirect.
94+
*/
95+
export class InsecureTokenEndpointError extends OAuthClientFlowError {
96+
/** The token endpoint URL that was rejected. */
97+
public readonly tokenEndpoint: string;
98+
99+
constructor(tokenEndpoint: string) {
100+
super(
101+
`Refusing to send credentials to non-https token endpoint '${tokenEndpoint}'. ` +
102+
`OAuth token requests MUST use TLS (localhost / 127.0.0.1 / ::1 are exempt).`
103+
);
104+
this.tokenEndpoint = tokenEndpoint;
105+
}
106+
}

packages/client/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,15 @@ export {
3333
prepareAuthorizationCodeRequest,
3434
refreshAuthorization,
3535
registerClient,
36+
resolveClientMetadata,
3637
selectClientAuthMethod,
3738
selectResourceURL,
3839
startAuthorization,
3940
UnauthorizedError,
4041
validateAuthorizationResponseIssuer,
4142
validateClientMetadataUrl
4243
} from './client/auth.js';
43-
export { IssuerMismatchError, OAuthClientFlowError } from './client/authErrors.js';
44+
export { InsecureTokenEndpointError, IssuerMismatchError, OAuthClientFlowError, RegistrationRejectedError } from './client/authErrors.js';
4445
export type {
4546
AssertionCallback,
4647
ClientCredentialsProviderOptions,

0 commit comments

Comments
 (0)