diff --git a/.changeset/oauth-provider-infer-loopback-native.md b/.changeset/oauth-provider-infer-loopback-native.md new file mode 100644 index 0000000000..e29880d6ac --- /dev/null +++ b/.changeset/oauth-provider-infer-loopback-native.md @@ -0,0 +1,5 @@ +--- +"@better-auth/oauth-provider": patch +--- + +With `clientRegistrationDefaultApplicationType: "infer"`, clients that register an `http` callback on `localhost`, `127.0.0.1`, or `[::1]` without `application_type` now register as `native`. This includes older Claude Code releases, which register `http://127.0.0.1:/callback`. Current Claude Code releases send `application_type: "native"` and were not affected. Before, `"infer"` chose `native` only for custom-scheme callbacks, so these registrations were treated as `web` and rejected with `invalid_redirect_uri`. The `"web"` default and the `"native"` setting are unchanged, and an `application_type` the client sends is never overridden. diff --git a/docs/content/docs/plugins/mcp.mdx b/docs/content/docs/plugins/mcp.mdx index d2945d142a..72a4e1f7dc 100644 --- a/docs/content/docs/plugins/mcp.mdx +++ b/docs/content/docs/plugins/mcp.mdx @@ -163,9 +163,12 @@ mcp({ resource: "https://api.example.com/mcp", allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, + clientRegistrationDefaultApplicationType: "infer", }) ``` +Some MCP clients, such as Cursor and older Claude Code releases, omit `application_type` and register custom-scheme callbacks or `http` callbacks on `localhost`, `127.0.0.1`, or `[::1]`, which the `web` default rejects. `clientRegistrationDefaultApplicationType: "infer"` registers those clients as `native`; see [application type](/docs/plugins/oauth-provider#dynamic-registration-endpoint). + The registration endpoint is absent from discovery unless DCR is enabled. A DCR request does not need Better Auth's `resources` extension: `mcp()` adds its canonical resource as a server-owned default. ### Protecting an MCP Route diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx index 8f6c122cde..fb1e0e6549 100644 --- a/docs/content/docs/plugins/oauth-provider.mdx +++ b/docs/content/docs/plugins/oauth-provider.mdx @@ -464,15 +464,15 @@ Application type is independent of client authentication. For example, a native 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`. A [Client ID Metadata Document](/docs/plugins/cimd) may omit it; Better Auth stores that omission as `null` and validates redirects against the safe union of web and native forms. -Some native MCP clients omit `application_type` during dynamic registration. Cursor, for example, registers `cursor://anysphere.cursor-mcp/oauth/callback`, an `https` callback, and `http://localhost:8787/callback` without it, so the `web` default rejects the registration. `clientRegistrationDefaultApplicationType` sets the type that a dynamic registration gets when it omits the field: +Some native MCP clients omit `application_type` during dynamic registration. Cursor, for example, registers `cursor://anysphere.cursor-mcp/oauth/callback`, an `https` callback, and `http://localhost:8787/callback` without it, and older Claude Code releases register a single `http://127.0.0.1:/callback`, so the `web` default rejects both registrations. Current Claude Code releases send `application_type: "native"` and register without this option. `clientRegistrationDefaultApplicationType` sets the type that a dynamic registration gets when it omits the field: -| Value | Omitted `application_type` becomes | -| ----------------- | ------------------------------------------------------------------------- | -| `"web"` (default) | `web`, as OIDC Registration §2 specifies | -| `"native"` | `native` | -| `"infer"` | `native` when any redirect URI uses a non-http(s) scheme, otherwise `web` | +| Value | Omitted `application_type` becomes | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `"web"` (default) | `web`, as OIDC Registration §2 specifies | +| `"native"` | `native` | +| `"infer"` | `native` when any redirect URI uses a non-http(s) scheme or `http` on `localhost`, `127.0.0.1`, or `[::1]`; otherwise `web` | -Every redirect URI is then validated against the rules for the resulting type. Under `"infer"`, a client that lists a custom scheme becomes `native`, so its `http` URIs must still be loopback. A client that lists only `http` and `https` URIs stays `web`. An `application_type` the client sends is never overridden. The option applies only to dynamic registration; managed clients and Client ID Metadata Documents are unaffected. +Every redirect URI is then validated against the rules for the resulting type. Under `"infer"`, a client that lists a custom scheme or an `http` loopback URI becomes `native`, so its other `http` URIs must still be loopback. A client that lists only `https` URIs stays `web`. An `application_type` the client sends is never overridden. The option applies only to dynamic registration; managed clients and Client ID Metadata Documents are unaffected. ```ts title="auth.ts" oauthProvider({ diff --git a/packages/oauth-provider/src/register.test.ts b/packages/oauth-provider/src/register.test.ts index a7f430ae52..24c4f3eacb 100644 --- a/packages/oauth-provider/src/register.test.ts +++ b/packages/oauth-provider/src/register.test.ts @@ -1946,7 +1946,28 @@ describe("oauth register - clientRegistrationDefaultApplicationType", async () = ]); }); - it("keeps the web classification under infer when every redirect URI is http(s)", async () => { + /** + * @see https://github.com/croffasia/itsaplan/issues/563 + */ + it.each([ + "http://127.0.0.1:33418/callback", + "http://localhost:33418/callback", + "http://[::1]:33418/callback", + ])( + "infers native for an http loopback redirect URI: %s", + async (redirectUri) => { + const register = await instanceWithDefault("infer"); + const response = await register({ + token_endpoint_auth_method: "none", + redirect_uris: [redirectUri], + }); + expect(response.status).toBe(201); + const body = (await response.json()) as OAuthClient; + expect(body.application_type).toBe("native"); + }, + ); + + it("keeps the web classification under infer when every redirect URI is https", async () => { const register = await instanceWithDefault("infer"); const https = await register({ redirect_uris: ["https://rp.example.com/callback"], diff --git a/packages/oauth-provider/src/register.ts b/packages/oauth-provider/src/register.ts index 6ab2d48287..2cb8eddadf 100644 --- a/packages/oauth-provider/src/register.ts +++ b/packages/oauth-provider/src/register.ts @@ -117,10 +117,10 @@ function isNonHttpRedirectUri(redirectUri: string): boolean { /** * Server-owned default for dynamic registrations that omit `application_type`. * `"infer"` classifies as native when any redirect URI uses a non-http(s) - * scheme, because a web client can never register one. Every URI is then - * validated against the native rules. A sent `application_type` is never - * overridden because `applyOAuthClientRegistrationDefaults` only fills the - * absent field. + * scheme or http on an exact loopback host, because a web client can never + * register either. Every URI is then validated against the native rules. A + * sent `application_type` is never overridden because + * `applyOAuthClientRegistrationDefaults` only fills the absent field. */ function resolveDynamicRegistrationDefaultApplicationType( opts: OAuthOptions, @@ -128,7 +128,9 @@ function resolveDynamicRegistrationDefaultApplicationType( ): "web" | "native" { const configured = opts.clientRegistrationDefaultApplicationType ?? "web"; if (configured !== "infer") return configured; - return (client.redirect_uris ?? []).some(isNonHttpRedirectUri) + return (client.redirect_uris ?? []).some( + (uri) => isNonHttpRedirectUri(uri) || isExactLoopbackHttpRedirectUri(uri), + ) ? "native" : "web"; } @@ -153,6 +155,13 @@ function getRawHttpHostname(redirectUri: string): string | null { return (hostAndPort.split(":")[0] ?? "").toLowerCase(); } +function isExactLoopbackHttpRedirectUri(redirectUri: string): boolean { + const hostname = getRawHttpHostname(redirectUri); + return ( + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" + ); +} + function assertDynamicRegistrationResourcesAllowed( opts: OAuthOptions, requestedResources: readonly string[], @@ -225,11 +234,6 @@ function validateClientRedirectUri( } const isRedirectLoopback = isLoopbackIP(url.hostname) || url.hostname === "localhost"; - const rawHttpHostname = getRawHttpHostname(redirectUri); - const isAllowedNativeHttpLoopback = - rawHttpHostname === "localhost" || - rawHttpHostname === "127.0.0.1" || - rawHttpHostname === "[::1]"; if (applicationType === "web") { if (!isHttps || isRedirectLoopback) { @@ -249,7 +253,7 @@ function validateClientRedirectUri( return; } if (isHttp) { - if (!isAllowedNativeHttpLoopback) { + if (!isExactLoopbackHttpRedirectUri(redirectUri)) { invalidRedirectUri( `native clients may use http only on the exact loopback hosts localhost, 127.0.0.1, or [::1]: ${redirectUri}`, ); diff --git a/packages/oauth-provider/src/types/index.ts b/packages/oauth-provider/src/types/index.ts index 776a16ecc9..e48c9211c2 100644 --- a/packages/oauth-provider/src/types/index.ts +++ b/packages/oauth-provider/src/types/index.ts @@ -884,14 +884,16 @@ export interface OAuthOptions< * field. * * - `"web"`: the OIDC Registration §2 default. Omitted `application_type` is - * treated as `web`, so custom-scheme redirect URIs are rejected. + * treated as `web`, so custom-scheme and loopback redirect URIs are + * rejected. * - `"native"`: omitted `application_type` is treated as `native`. * - `"infer"`: omitted `application_type` is treated as `native` when any - * `redirect_uris` entry uses a non-http(s) scheme; otherwise `web`. Every - * redirect URI is then validated against the native rules. Lets native MCP - * clients that omit `application_type` (for example Cursor, which also - * registers https and loopback callbacks) register without loosening - * web-client redirect policy. + * `redirect_uris` entry uses a non-http(s) scheme or is `http` on + * `localhost`, `127.0.0.1`, or `[::1]`; otherwise `web`. Every redirect URI + * of an inferred native client is then validated against the native rules. + * Lets native MCP clients that omit `application_type` (for example Cursor, + * or older Claude Code releases with an `http://127.0.0.1` callback) + * register without loosening web-client redirect policy. * * An `application_type` the client actually sends is never overridden. * Applies only to dynamic client registration; managed clients and Client ID