mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-18 11:32:15 -05:00
fix(oauth-provider): infer native for http loopback redirect URIs (#11798)
This commit is contained in:
@@ -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:<port>/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.
|
||||
@@ -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
|
||||
|
||||
@@ -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:<port>/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({
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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<Scope[]>,
|
||||
@@ -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<Scope[]>,
|
||||
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}`,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user