From 8a5046b6d511e576b0658ada7cc191497476f547 Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Tue, 28 Jul 2026 19:16:41 -0400 Subject: [PATCH] feat(oauth-provider): apply application_type to metadata document clients Client ID Metadata Documents are the registration mechanism the MCP 2026-07-28 spec recommends, and dynamic client registration the one it deprecates, but the `application_type` redirect URI constraint reached only the deprecated path: the field was dropped by the metadata document allowlist before validation ran. `type` and `application_type` both describe the client profile, so a registration sending both must keep them consistent. Cite RFC 8252 for the native redirect URI rule actually enforced; OIDC Registration predates claimed https links and does not permit them. --- .changeset/register-application-type.md | 5 +- docs/content/docs/plugins/oauth-provider.mdx | 4 +- packages/cimd/src/cimd.test.ts | 64 ++++++++++++++++++++ packages/cimd/src/client-store.ts | 1 + packages/oauth-provider/src/register.test.ts | 21 +++++++ packages/oauth-provider/src/register.ts | 26 ++++++-- packages/oauth-provider/src/types/oauth.ts | 5 +- packages/oauth-provider/src/types/zod.ts | 5 +- 8 files changed, 117 insertions(+), 14 deletions(-) diff --git a/.changeset/register-application-type.md b/.changeset/register-application-type.md index 6006357916..82fb838d86 100644 --- a/.changeset/register-application-type.md +++ b/.changeset/register-application-type.md @@ -1,5 +1,8 @@ --- "@better-auth/oauth-provider": minor +"@better-auth/cimd": minor --- -Accept `application_type` (OIDC Registration §2, required of MCP clients by the MCP 2026-07-28 spec) in dynamic client registration. When provided, redirect URIs are constrained to the declared type: `native` clients may use custom schemes, loopback `http`, or `https`; `web` clients require `https` on non-loopback hosts. Omitting the parameter keeps prior behavior. +Client registration accepts `application_type` (OIDC Registration §2), which the MCP 2026-07-28 spec requires MCP clients to send. When provided, redirect URIs are constrained to what the declared type allows: `native` clients may use custom schemes, loopback `http`, or claimed `https` links; `web` clients require `https` on non-loopback hosts. Omitting the parameter leaves redirect URIs unconstrained. + +The constraint applies to both registration paths: dynamic client registration and Client ID Metadata Documents, which the same spec revision makes the preferred mechanism. A registration that sends both `application_type` and `type` must keep them consistent, so a client record cannot claim two different profiles. diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx index 1c69d01468..20d8f692d8 100644 --- a/docs/content/docs/plugins/oauth-provider.mdx +++ b/docs/content/docs/plugins/oauth-provider.mdx @@ -448,7 +448,9 @@ const client = await authClient.oauth2.register({ For all endpoint parameters, see [RFC 7591 Registration](https://datatracker.ietf.org/doc/html/rfc7591#section-2). -When `application_type` ([OIDC Registration §2](https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata)) is provided, redirect URIs are constrained to what the declared type allows: `native` clients may use custom schemes, loopback `http` redirects, or claimed `https` links — plain `http` on a routable host is rejected; `web` clients require `https` redirect URIs on non-loopback hosts. MCP clients are required to send an appropriate `application_type` by the [MCP 2026-07-28 spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#application-type-and-redirect-uri-constraints). When omitted, no `application_type` constraints are applied. +When `application_type` ([OIDC Registration §2](https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata)) is provided, redirect URIs are constrained to what the declared type allows. A `native` client may use custom schemes, loopback `http` redirects, or claimed `https` links ([RFC 8252 §7](https://datatracker.ietf.org/doc/html/rfc8252#section-7)); plain `http` on a routable host is rejected. A `web` client requires `https` redirect URIs on non-loopback hosts. Sending both `application_type` and `type` requires them to agree. When `application_type` is omitted, no constraints are applied. + +The [MCP 2026-07-28 spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#application-type-and-redirect-uri-constraints) requires MCP clients to send an appropriate `application_type`. The same constraints apply to clients identified by a [Client ID Metadata Document](/docs/plugins/cimd). Note the following parameters are not yet supported: diff --git a/packages/cimd/src/cimd.test.ts b/packages/cimd/src/cimd.test.ts index 5c7a7d85ae..835a0f7211 100644 --- a/packages/cimd/src/cimd.test.ts +++ b/packages/cimd/src/cimd.test.ts @@ -274,4 +274,68 @@ describe("Client ID Metadata Document - integration", async () => { }); expect(errorStatus).toBeGreaterThanOrEqual(400); }); + + it("enforces application_type redirect URI constraints from the metadata document", async ({ + onTestFinished, + }) => { + const originalFetch = globalThis.fetch.bind(globalThis); + const nativeClientUrl = "https://native.example.com/client-metadata.json"; + const loopbackRedirect = "http://127.0.0.1:5099/callback"; + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + if (url === nativeClientUrl) { + return Promise.resolve( + new Response( + JSON.stringify({ + client_id: nativeClientUrl, + client_name: "Mislabelled Native Client", + // A loopback interception redirect belongs to a native + // client; declaring `web` contradicts it. + application_type: "web", + redirect_uris: [loopbackRedirect], + token_endpoint_auth_method: "none", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + } + return originalFetch(input, init); + }), + ); + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + + const { headers } = await signInWithTestUser(); + const authedClient = createAuthClient({ + plugins: [oauthProviderClient()], + baseURL: authServerBaseUrl, + fetchOptions: { customFetchImpl, headers }, + }); + + const authorizeUrl = + `${authServerBaseUrl}/api/auth/oauth2/authorize` + + `?client_id=${encodeURIComponent(nativeClientUrl)}` + + `&response_type=code` + + `&redirect_uri=${encodeURIComponent(loopbackRedirect)}` + + `&scope=openid` + + `&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM` + + `&code_challenge_method=S256`; + + let errorStatus = 0; + await authedClient.$fetch(authorizeUrl, { + method: "GET", + onError(ctx) { + errorStatus = ctx.response.status; + }, + }); + expect(errorStatus).toBeGreaterThanOrEqual(400); + }); }); diff --git a/packages/cimd/src/client-store.ts b/packages/cimd/src/client-store.ts index 18fcfc9087..db34edda2e 100644 --- a/packages/cimd/src/client-store.ts +++ b/packages/cimd/src/client-store.ts @@ -98,6 +98,7 @@ const ALLOWED_METADATA_FIELDS = new Set([ "software_statement", "post_logout_redirect_uris", "subject_type", + "application_type", "type", "jwks", "jwks_uri", diff --git a/packages/oauth-provider/src/register.test.ts b/packages/oauth-provider/src/register.test.ts index acd01374bb..9e3de8319b 100644 --- a/packages/oauth-provider/src/register.test.ts +++ b/packages/oauth-provider/src/register.test.ts @@ -1507,6 +1507,27 @@ describe("oauth register - application_type", async () => { expect(body.application_type).toBe("web"); }); + it("rejects an application_type that contradicts type", async () => { + const response = await register({ + type: "web", + application_type: "native", + redirect_uris: ["https://rp.example.com/callback"], + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error_description?: string }; + expect(body.error_description).toContain("conflicts with type"); + }); + + it("accepts a user-agent-based client declaring the web application type", async () => { + const response = await register({ + type: "user-agent-based", + application_type: "web", + token_endpoint_auth_method: "none", + redirect_uris: ["https://spa.example.com/callback"], + }); + expect(response.status).toBe(201); + }); + it("keeps prior behavior when application_type is omitted", async () => { // OIDC Registration defaults application_type to "web", but enforcing // that default would break existing localhost integrations; constraints diff --git a/packages/oauth-provider/src/register.ts b/packages/oauth-provider/src/register.ts index bde6684d10..52ec6eb297 100644 --- a/packages/oauth-provider/src/register.ts +++ b/packages/oauth-provider/src/register.ts @@ -295,10 +295,24 @@ export async function checkOAuthClient( }); } - // OIDC Registration §2 / MCP SEP-837: when `application_type` is sent, - // constrain redirect URIs to what the declared type allows. Only enforced - // when the parameter is explicit, so registrations predating this check - // keep working. + // `type` and `application_type` both describe the client profile, so a + // registration that sends both must not contradict itself. + if (clientWithDefaults.type && clientWithDefaults.application_type) { + const impliedApplicationType = + clientWithDefaults.type === "native" ? "native" : "web"; + if (impliedApplicationType !== clientWithDefaults.application_type) { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: `application_type '${clientWithDefaults.application_type}' conflicts with type '${clientWithDefaults.type}'`, + }); + } + } + + // OIDC Registration §2 `application_type`, required of MCP clients by + // SEP-837. Native redirect URIs follow RFC 8252 §7 (custom scheme, loopback + // http, or claimed https link) rather than OIDC's narrower list, which + // predates claimed https links. Only enforced when the parameter is + // explicit, so registrations that omit it keep working. if (clientWithDefaults.application_type) { for (const uri of clientWithDefaults.redirect_uris ?? []) { const url = new URL(uri); @@ -307,8 +321,8 @@ export async function checkOAuthClient( const isLoopback = (isHttp || isHttps) && isLoopbackHost(url.hostname); if (clientWithDefaults.application_type === "native") { // Native clients redirect to custom schemes, loopback http - // interception servers, or claimed https links — never plain - // http on a routable host. + // interception servers, or claimed https links; never plain http + // on a routable host. if (isHttp && !isLoopback) { throw new APIError("BAD_REQUEST", { error: "invalid_redirect_uri", diff --git a/packages/oauth-provider/src/types/oauth.ts b/packages/oauth-provider/src/types/oauth.ts index 178cf83c0b..3be6f56c0d 100644 --- a/packages/oauth-provider/src/types/oauth.ts +++ b/packages/oauth-provider/src/types/oauth.ts @@ -390,9 +390,8 @@ export interface OAuthClient { /** * OIDC Registration application type, constraining redirect URI schemes and * hosts when provided: `native` clients use custom schemes, loopback `http` - * redirects, or claimed `https` links; `web` clients require `https` on a - * non-loopback host. Distinct from `type`, which tracks the RFC 6749 client - * profile. + * redirects, or claimed `https` links (RFC 8252 §7); `web` clients require + * `https` on a non-loopback host. Must agree with `type` when both are sent. * * @default "web" * @see https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata diff --git a/packages/oauth-provider/src/types/zod.ts b/packages/oauth-provider/src/types/zod.ts index be22e9fe13..78155bd8b4 100644 --- a/packages/oauth-provider/src/types/zod.ts +++ b/packages/oauth-provider/src/types/zod.ts @@ -207,9 +207,8 @@ export const clientRegistrationRequestSchema = z.object({ grant_types: z.array(z.string().trim().min(1)).min(1).optional(), response_types: z.array(z.enum(["code"])).optional(), type: z.enum(["web", "native", "user-agent-based"]).optional(), - // OIDC Registration §2 / MCP SEP-837: constrains redirect URI schemes and - // hosts when provided. Distinct from `type`, which tracks the RFC 6749 - // client profile. + // OIDC Registration §2: constrains redirect URI schemes and hosts when + // provided, and must agree with `type` when both are sent. application_type: z.enum(["web", "native"]).optional(), subject_type: z.enum(["public", "pairwise"]).optional(), // RFC 9449 §5.2: client asks for DPoP-bound access tokens.