diff --git a/.changeset/register-application-type.md b/.changeset/register-application-type.md new file mode 100644 index 0000000000..6006357916 --- /dev/null +++ b/.changeset/register-application-type.md @@ -0,0 +1,5 @@ +--- +"@better-auth/oauth-provider": 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. diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx index dbd3e0e1dc..1c69d01468 100644 --- a/docs/content/docs/plugins/oauth-provider.mdx +++ b/docs/content/docs/plugins/oauth-provider.mdx @@ -422,7 +422,7 @@ oauthProvider({ To enable open client registration without a Better Auth session, additionally set `allowUnauthenticatedClientRegistration: true` in your auth config. Public clients are registered with `token_endpoint_auth_method: "none"`. Confidential clients receive a one-time `client_secret` in the registration response. - For MCP public-client identity, [Client ID Metadata Documents](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/991) are [available](/docs/plugins/cimd). + For MCP public-client identity, use [Client ID Metadata Documents](/docs/plugins/cimd). The [MCP 2026-07-28 spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration) deprecates Dynamic Client Registration in favor of CIMD; DCR remains supported for backwards compatibility. ```ts title="auth.ts" @@ -448,6 +448,8 @@ 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. + Note the following parameters are not yet supported: * `sector_identifier_uri` diff --git a/packages/oauth-provider/src/register.test.ts b/packages/oauth-provider/src/register.test.ts index 74475f728b..acd01374bb 100644 --- a/packages/oauth-provider/src/register.test.ts +++ b/packages/oauth-provider/src/register.test.ts @@ -1424,3 +1424,98 @@ describe("oauth register - protected dynamic registration", async () => { expect(tokenBody.user_id).toBeUndefined(); }); }); + +describe("oauth register - application_type", async () => { + const authServerBaseUrl = "http://localhost:3000"; + const { customFetchImpl } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + allowDynamicClientRegistration: true, + allowUnauthenticatedClientRegistration: true, + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const register = (body: Record) => + customFetchImpl(`${authServerBaseUrl}/api/auth/oauth2/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + + it("registers a native client with loopback and custom-scheme redirect URIs", async () => { + const response = await register({ + application_type: "native", + token_endpoint_auth_method: "none", + redirect_uris: [ + "http://localhost:3005/callback", + "http://127.0.0.1:3005/callback", + "com.example.app:/callback", + ], + }); + expect(response.status).toBe(201); + const body = (await response.json()) as OAuthClient; + expect(body.application_type).toBe("native"); + }); + + it("rejects a native client with an http redirect URI on a routable host", async () => { + const response = await register({ + application_type: "native", + token_endpoint_auth_method: "none", + redirect_uris: ["http://example.com/callback"], + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error?: string }; + expect(body.error).toBe("invalid_redirect_uri"); + }); + + it("rejects a web client with an http redirect URI", async () => { + const response = await register({ + application_type: "web", + redirect_uris: ["http://example.com/callback"], + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error?: string }; + expect(body.error).toBe("invalid_redirect_uri"); + }); + + it("rejects a web client with a loopback redirect URI", async () => { + const response = await register({ + application_type: "web", + redirect_uris: ["https://localhost:3005/callback"], + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error?: string }; + expect(body.error).toBe("invalid_redirect_uri"); + }); + + it("registers a web client with an https redirect URI", async () => { + const response = await register({ + application_type: "web", + redirect_uris: ["https://rp.example.com/callback"], + }); + expect(response.status).toBe(201); + const body = (await response.json()) as OAuthClient; + expect(body.application_type).toBe("web"); + }); + + 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 + // only apply when the parameter is sent explicitly. + const response = await register({ + redirect_uris: ["http://localhost:5000/api/auth/callback/test"], + }); + expect(response.status).toBe(201); + const body = (await response.json()) as OAuthClient; + expect(body.application_type).toBeUndefined(); + }); +}); diff --git a/packages/oauth-provider/src/register.ts b/packages/oauth-provider/src/register.ts index b00117c3f3..bde6684d10 100644 --- a/packages/oauth-provider/src/register.ts +++ b/packages/oauth-provider/src/register.ts @@ -295,6 +295,35 @@ 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. + if (clientWithDefaults.application_type) { + for (const uri of clientWithDefaults.redirect_uris ?? []) { + const url = new URL(uri); + const isHttp = url.protocol === "http:"; + const isHttps = url.protocol === "https:"; + 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. + if (isHttp && !isLoopback) { + throw new APIError("BAD_REQUEST", { + error: "invalid_redirect_uri", + error_description: `native clients must not use http redirect URIs on non-loopback hosts: ${uri}`, + }); + } + } else if (!isHttps || isLoopback) { + throw new APIError("BAD_REQUEST", { + error: "invalid_redirect_uri", + error_description: `web clients require https redirect URIs on non-loopback hosts: ${uri}`, + }); + } + } + } + // Validate correlation between grant_types and response_types const supportedGrantTypes = new Set(getSupportedGrantTypes(opts)); for (const grantType of grantTypes) { diff --git a/packages/oauth-provider/src/types/oauth.ts b/packages/oauth-provider/src/types/oauth.ts index 291f89429c..178cf83c0b 100644 --- a/packages/oauth-provider/src/types/oauth.ts +++ b/packages/oauth-provider/src/types/oauth.ts @@ -387,6 +387,17 @@ export interface OAuthClient { grant_types?: GrantType[]; response_types?: "code"[]; // | "token" // NEVER SUPPORT - deprecated in OAuth 2.1 + /** + * 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. + * + * @default "web" + * @see https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata + */ + application_type?: "web" | "native"; //---- RFC6749 Spec ----// public?: boolean; type?: "web" | "native" | "user-agent-based"; diff --git a/packages/oauth-provider/src/types/zod.ts b/packages/oauth-provider/src/types/zod.ts index dfd879fdcf..be22e9fe13 100644 --- a/packages/oauth-provider/src/types/zod.ts +++ b/packages/oauth-provider/src/types/zod.ts @@ -207,6 +207,10 @@ 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. + 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. dpop_bound_access_tokens: z.boolean().optional(),