diff --git a/.changeset/native-host-bearing-redirect.md b/.changeset/native-host-bearing-redirect.md new file mode 100644 index 0000000000..8a12d2fe55 --- /dev/null +++ b/.changeset/native-host-bearing-redirect.md @@ -0,0 +1,11 @@ +--- +"@better-auth/core": patch +"@better-auth/oauth-provider": patch +"@better-auth/cimd": patch +--- + +Native OAuth clients and Client ID Metadata Documents can now use custom-scheme redirect URIs with a host and a path, such as Cursor's `cursor://anysphere.cursor-mcp/oauth/callback`. Previously, only the authority-free reverse-domain form, such as `com.example.app:/callback`, was accepted, and these registrations failed with `invalid_redirect_uri`. + +Cursor also omits `application_type` when it registers, so the `web` default still rejects it. To accept Cursor, set the new `clientRegistrationDefaultApplicationType` option to `"infer"`. A dynamic registration that omits `application_type` then becomes `native` when any of its redirect URIs uses a custom scheme, and every redirect URI must pass the native rules. Setting `"native"` treats every registration that omits the field as `native`. The default remains `"web"`, and an `application_type` the client sends is never overridden. + +Pairwise clients (`subject_type: "pairwise"`) cannot use these host-bearing custom-scheme redirect URIs. Web clients are unaffected: they still accept only `https` redirect URIs on non-loopback hosts. diff --git a/docs/content/docs/plugins/cimd.mdx b/docs/content/docs/plugins/cimd.mdx index 7343fca3ae..a1ed9da7fa 100644 --- a/docs/content/docs/plugins/cimd.mdx +++ b/docs/content/docs/plugins/cimd.mdx @@ -149,7 +149,7 @@ export const cimdOptionsType = { type: "string[]", default: '["post_logout_redirect_uris", "client_uri"]', description: - "Metadata URL fields that must share the client_id origin. Redirect URIs are excluded by default because authorization still requires exact matching.", + "Metadata URL fields that must share the client_id origin. Redirect URIs are excluded by default because authorization still requires exact matching. Authority-free private-use URIs such as com.example.app:/logout have no origin and are exempt; host-bearing custom-scheme URIs in post_logout_redirect_uris must still match the client_id origin.", }, isMetadataDocumentUrlAllowed: { type: "(clientIdUrl, context) => boolean | Promise", diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx index 68ae5c9e69..44492bc151 100644 --- a/docs/content/docs/plugins/oauth-provider.mdx +++ b/docs/content/docs/plugins/oauth-provider.mdx @@ -440,12 +440,42 @@ const client = await authClient.oauth2.register({ For all endpoint parameters, see [RFC 7591 Registration](https://datatracker.ietf.org/doc/html/rfc7591#section-2). -`application_type` ([OIDC Registration §2](https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata)) defaults to `web` when omitted. A `web` client requires `https` redirect URIs on non-loopback hosts. A `native` client may use a claimed `https` URI, an `http` loopback URI on exactly `localhost`, `127.0.0.1`, or `[::1]` with any port, or an authority-free private-use URI with a reverse-domain scheme such as `com.example.app:/callback` ([RFC 8252 §7](https://datatracker.ietf.org/doc/html/rfc8252#section-7)). Loopback host matching uses the raw URI authority, so alternative numeric IPv4 spellings such as `127.1` are rejected before URL normalization. Better Auth also rejects credentials, fragments, malformed or reserved schemes such as `file:` and `mailto:`, loopback `https`, and routable-host `http` redirects. +`application_type` ([OIDC Registration §2](https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata)) defaults to `web` when omitted. A `web` client requires `https` redirect URIs on non-loopback hosts. A `native` client may use any of these redirect URI forms: + +* A claimed `https` URI on a non-loopback host. +* An `http` loopback URI on exactly `localhost`, `127.0.0.1`, or `[::1]`, with any port. +* An authority-free private-use URI with a reverse-domain scheme, such as `com.example.app:/callback`. This is the form [RFC 8252 §7.1](https://datatracker.ietf.org/doc/html/rfc8252#section-7.1) recommends. +* A custom-scheme URI with a host and a path, such as `cursor://anysphere.cursor-mcp/oauth/callback`. The scheme does not need to contain a period. A URI without a path, such as `cursor://anysphere.cursor-mcp`, is rejected. + +Loopback host matching uses the raw URI authority, so alternative numeric IPv4 spellings such as `127.1` are rejected before URL normalization. Better Auth also rejects credentials, fragments, malformed URIs, loopback `https`, and routable-host `http` redirects. Native clients cannot use the reserved schemes `ws:`, `wss:`, `file:`, `ftp:`, `mailto:`, `javascript:`, `data:`, `vbscript:`, `blob:`, `about:`, or `view-source:`. Any other scheme is accepted in the host-and-path form; an authority-free URI such as `myapp:/callback` still needs a reverse-domain scheme. Application type is independent of client authentication. For example, a native client may use `client_secret_post`, while a web client may use `token_endpoint_auth_method: "none"`. Authentication capability is derived only from `token_endpoint_auth_method`; the removed `type` and `public` metadata fields are not accepted or returned. 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: + +| 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` | + +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. + +```ts title="auth.ts" +oauthProvider({ + allowDynamicClientRegistration: true, + allowUnauthenticatedClientRegistration: true, + clientRegistrationDefaultApplicationType: "infer", + // ... other options +}) +``` + + + [RFC 8252 §8.4](https://datatracker.ietf.org/doc/html/rfc8252#section-8.4) says authorization servers SHOULD reject private-use schemes that do not contain a period. Better Auth accepts schemes such as `cursor:` for compatibility with native MCP clients. Any app on a device can register a private-use scheme, so another app may receive the authorization code. PKCE makes an intercepted code unusable without the code verifier. Public clients (`token_endpoint_auth_method: "none"`) always require PKCE. Confidential clients can opt out through `require_pkce: false` or `clientRegistrationRequirePKCE: false`; keep PKCE on for any client that uses a private-use redirect. See [PKCE Configuration](#pkce-configuration). + + Note the following parameters are not yet supported: * `sector_identifier_uri` @@ -1882,6 +1912,7 @@ JWT access tokens always use the real user ID as `sub`, since resource servers m **Limitations:** * `sector_identifier_uri` is not yet supported. All `redirect_uris` for a pairwise client must share the same host. Clients with redirect URIs on different hosts will be rejected at registration. + * Pairwise clients cannot use custom-scheme redirect URIs that include a host, such as `app://rp.example.com/callback`. Any app can register a custom scheme, so that host does not identify the client. * `pairwiseSecret` must be at least 32 characters long. * Rotating `pairwiseSecret` will change all pairwise `sub` values, breaking existing RP sessions. Treat this secret as permanent once set. diff --git a/packages/cimd/src/validate-metadata-document.test.ts b/packages/cimd/src/validate-metadata-document.test.ts index dc66acf5ea..6b956e582a 100644 --- a/packages/cimd/src/validate-metadata-document.test.ts +++ b/packages/cimd/src/validate-metadata-document.test.ts @@ -665,14 +665,25 @@ describe("validateCimdMetadata", () => { expect(result.valid).toBe(true); }); - it("rejects a private-use redirect URI with a naming authority", () => { + it("accepts a host-bearing private-use redirect URI", () => { const result = validateCimdMetadata(fetchUrl, { client_id: fetchUrl, - client_name: "Invalid Private-use Client", + client_name: "Host-bearing Private-use Client", redirect_uris: ["com.example.app://host/callback"], }); - expect(result.valid).toBe(false); - expect(result.error).toContain("redirect_uris"); + expect(result.valid).toBe(true); + }); + + /** + * @see https://github.com/better-auth/better-auth/issues/10946 + */ + it("accepts Cursor's host-bearing cursor:// redirect URI", () => { + const result = validateCimdMetadata(fetchUrl, { + client_id: fetchUrl, + client_name: "Cursor MCP Client", + redirect_uris: ["cursor://anysphere.cursor-mcp/oauth/callback"], + }); + expect(result.valid).toBe(true); }); it.each([ @@ -842,6 +853,40 @@ describe("validateCimdMetadata", () => { expect(result.valid).toBe(true); }); + it("accepts authority-free private-use post_logout_redirect_uris", () => { + const result = validateCimdMetadata(fetchUrl, { + client_id: fetchUrl, + client_name: "Native Logout Client", + redirect_uris: ["com.example.app:/callback"], + post_logout_redirect_uris: ["com.example.app:/logout"], + }); + expect(result.valid).toBe(true); + }); + + it("does not apply the loopback exception to host-bearing private-use post_logout_redirect_uris", () => { + const result = validateCimdMetadata(fetchUrl, { + client_id: fetchUrl, + client_name: "Native Logout Client", + redirect_uris: ["cursor://anysphere.cursor-mcp/oauth/callback"], + post_logout_redirect_uris: ["cursor://localhost/logout"], + }); + expect(result.valid).toBe(false); + expect(result.error).toContain("same origin"); + }); + + it("keeps host-bearing private-use post_logout_redirect_uris origin-bound", () => { + const result = validateCimdMetadata(fetchUrl, { + client_id: fetchUrl, + client_name: "Native Logout Client", + redirect_uris: ["cursor://anysphere.cursor-mcp/oauth/callback"], + post_logout_redirect_uris: [ + "cursor://anysphere.cursor-mcp/oauth/callback", + ], + }); + expect(result.valid).toBe(false); + expect(result.error).toContain("same origin"); + }); + it("validates client_uri for SSRF (private address)", () => { const result = validateCimdMetadata( fetchUrl, diff --git a/packages/cimd/src/validate-metadata-document.ts b/packages/cimd/src/validate-metadata-document.ts index 83d83d95d7..f7db75d315 100644 --- a/packages/cimd/src/validate-metadata-document.ts +++ b/packages/cimd/src/validate-metadata-document.ts @@ -4,7 +4,10 @@ import { isLoopbackHost, isPublicRoutableHost, } from "@better-auth/core/utils/host"; -import { isReverseDomainPrivateUseRedirectUri } from "@better-auth/core/utils/redirect-uri"; +import { + isNativePrivateUseRedirectUri, + isReverseDomainPrivateUseRedirectUri, +} from "@better-auth/core/utils/redirect-uri"; import type { OAuthClientMetadata } from "@better-auth/oauth-provider"; import { oauthClientMetadataSchema } from "@better-auth/oauth-provider"; import { @@ -136,7 +139,7 @@ function isAbsoluteRedirectUri(uri: string): boolean { return ( parsed.protocol === "http:" || parsed.protocol === "https:" || - isReverseDomainPrivateUseRedirectUri(parsed) + isNativePrivateUseRedirectUri(parsed) ); } catch { return false; @@ -375,7 +378,7 @@ export function validateCimdMetadata( const isRedirectField = key === "redirect_uris" || key === "post_logout_redirect_uris"; const isPrivateUseRedirect = - isRedirectField && isReverseDomainPrivateUseRedirectUri(uri); + isRedirectField && isNativePrivateUseRedirectUri(uri); if ( uri.protocol !== "https:" && uri.protocol !== "http:" && @@ -383,16 +386,27 @@ export function validateCimdMetadata( ) { return { valid: false, - error: `all values for ${key} must use HTTP(S) or an authority-free private-use scheme`, + error: `all values for ${key} must use HTTP(S) or a private-use scheme`, }; } - if (isPrivateUseRedirect) { + // Native private-use URIs have no HTTP origin, so they skip + // origin-binding. In post_logout_redirect_uris only the + // authority-free reverse-domain form skips it; host-bearing custom + // schemes there stay origin-bound. + if ( + isPrivateUseRedirect && + (key === "redirect_uris" || isReverseDomainPrivateUseRedirectUri(uri)) + ) { continue; } // Loopback redirect URIs are allowed for local/native app flows - // (RFC 8252); this exception applies only to redirect URI fields. - const localhostAllowed = isRedirectField && isLoopbackHost(uri.hostname); + // (RFC 8252); this exception applies only to HTTP(S) URIs in redirect + // URI fields. A custom scheme with a loopback host stays origin-bound. + const localhostAllowed = + isRedirectField && + (uri.protocol === "http:" || uri.protocol === "https:") && + isLoopbackHost(uri.hostname); if (uri.origin !== clientIdOrigin && !localhostAllowed) { return { valid: false, diff --git a/packages/core/src/utils/redirect-uri.ts b/packages/core/src/utils/redirect-uri.ts index e6bcc126f4..099ef6296d 100644 --- a/packages/core/src/utils/redirect-uri.ts +++ b/packages/core/src/utils/redirect-uri.ts @@ -5,6 +5,22 @@ import { DANGEROUS_URL_SCHEMES } from "./url"; const REVERSE_DOMAIN_PRIVATE_USE_SCHEME = /^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i; +const NON_PRIVATE_USE_SCHEMES = new Set([ + "http:", + "https:", + "ws:", + "wss:", + "file:", + "ftp:", + "mailto:", + "javascript:", + "data:", + "vbscript:", + "blob:", + "about:", + "view-source:", +]); + /** * Returns whether a parsed redirect URI uses an authority-free, reverse-domain * private-use scheme as recommended by RFC 8252 §7.1. @@ -22,6 +38,39 @@ export function isReverseDomainPrivateUseRedirectUri(uri: URL): boolean { ); } +/** + * Returns whether a parsed redirect URI uses a non-reserved custom scheme with + * an authority component and a path (for example + * `cursor://anysphere.cursor-mcp/oauth/callback`). + * + * RFC 8252 §7.1 recommends the authority-free reverse-domain form, and §8.4 + * says authorization servers SHOULD reject schemes without a period. Better + * Auth deliberately does not enforce either, because native MCP clients such + * as Cursor register host-bearing, period-free URIs. Bare-authority forms + * (`cursor://host`, `cursor://host?x=1`) are rejected because non-special + * schemes parse an empty pathname. Web, script, and browser-internal schemes + * in `NON_PRIVATE_USE_SCHEMES` are rejected; any other scheme is accepted. + * + * @see https://github.com/better-auth/better-auth/issues/10946 + */ +function isHostBearingPrivateUseRedirectUri(uri: URL): boolean { + if (NON_PRIVATE_USE_SCHEMES.has(uri.protocol)) { + return false; + } + if (uri.host.length === 0) { + return false; + } + return uri.pathname.startsWith("/"); +} + +/** RFC 8252 recommended form or a host-bearing non-reserved custom scheme. */ +export function isNativePrivateUseRedirectUri(uri: URL): boolean { + return ( + isReverseDomainPrivateUseRedirectUri(uri) || + isHostBearingPrivateUseRedirectUri(uri) + ); +} + /** * Zod schema for OAuth redirect URIs and other developer-supplied URLs that the * server stores and later hands back to a browser. diff --git a/packages/core/src/utils/url.test.ts b/packages/core/src/utils/url.test.ts index 07e428c847..4db4b4b556 100644 --- a/packages/core/src/utils/url.test.ts +++ b/packages/core/src/utils/url.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + isNativePrivateUseRedirectUri, isReverseDomainPrivateUseRedirectUri, SafeUrlSchema, } from "./redirect-uri"; @@ -189,5 +190,43 @@ describe("isReverseDomainPrivateUseRedirectUri", () => { new URL("com.example.app:///callback"), ), ).toBe(false); + expect( + isReverseDomainPrivateUseRedirectUri( + new URL("cursor://anysphere.cursor-mcp/oauth/callback"), + ), + ).toBe(false); + }); +}); + +describe("isNativePrivateUseRedirectUri", () => { + /** + * @see https://github.com/better-auth/better-auth/issues/10946 + */ + it.each([ + "cursor://anysphere.cursor-mcp/oauth/callback", + "com.example.app://host/callback", + "com.example.app:/callback", + ])("accepts %s", (uri) => { + expect(isNativePrivateUseRedirectUri(new URL(uri))).toBe(true); + }); + + it.each([ + "cursor://anysphere.cursor-mcp", + "cursor://anysphere.cursor-mcp?x=1", + "myapp:/callback", + ])("rejects %s, which has neither a host and path nor a reverse-domain scheme", (uri) => { + expect(isNativePrivateUseRedirectUri(new URL(uri))).toBe(false); + }); + + it.each([ + "https://example.com/cb", + "file://host/callback", + "ws://host/callback", + "wss://host/callback", + "blob://host/callback", + "about://host/callback", + "view-source://host/callback", + ])("rejects reserved scheme %s", (uri) => { + expect(isNativePrivateUseRedirectUri(new URL(uri))).toBe(false); }); }); diff --git a/packages/oauth-provider/src/pairwise.test.ts b/packages/oauth-provider/src/pairwise.test.ts index 438772a742..f40790aeb7 100644 --- a/packages/oauth-provider/src/pairwise.test.ts +++ b/packages/oauth-provider/src/pairwise.test.ts @@ -486,6 +486,83 @@ describe("pairwise DCR validation", async () => { }); }); +/** + * A custom URI scheme is not bound to the host in its authority, so that host + * cannot identify a pairwise sector. + * + * @see https://openid.net/specs/openid-connect-core-1_0.html#PairwiseAlg + */ +describe("pairwise with custom-scheme redirect URIs", async () => { + const { auth, signInWithTestUser } = await getTestInstance({ + baseURL: "http://localhost:3000", + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "test-pairwise-secret-key-32chars!!", + }), + ], + }); + const { headers } = await signInWithTestUser(); + const customSchemeRedirect = "app://rp.example.com/callback"; + + const errorCode = (error: unknown) => + error instanceof APIError + ? (error.body as { error?: string } | undefined)?.error + : undefined; + + it("rejects a pairwise client with a custom-scheme redirect URI that includes a host", async () => { + const error = await auth.api + .adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [customSchemeRedirect], + application_type: "native", + token_endpoint_auth_method: "none", + subject_type: "pairwise", + }, + }) + .catch((e: unknown) => e); + expect(errorCode(error)).toBe("invalid_client_metadata"); + }); + + it("accepts the same client without pairwise", async () => { + const client = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [customSchemeRedirect], + application_type: "native", + token_endpoint_auth_method: "none", + }, + }); + expect(client.client_id).toEqual(expect.any(String)); + }); + + it("rejects moving a pairwise client to a custom-scheme redirect URI with a host", async () => { + const client = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: ["https://rp.example.com/callback"], + subject_type: "pairwise", + }, + }); + const error = await auth.api + .adminUpdateOAuthClient({ + headers, + body: { + client_id: client.client_id, + update: { + application_type: "native", + redirect_uris: [customSchemeRedirect], + }, + }, + }) + .catch((e: unknown) => e); + expect(errorCode(error)).toBe("invalid_client_metadata"); + }); +}); + describe("pairwise configuration validation", () => { it("should reject pairwiseSecret shorter than 32 characters", () => { expect(() => diff --git a/packages/oauth-provider/src/register.test.ts b/packages/oauth-provider/src/register.test.ts index f20cb5c71e..a7f430ae52 100644 --- a/packages/oauth-provider/src/register.test.ts +++ b/packages/oauth-provider/src/register.test.ts @@ -1801,6 +1801,8 @@ describe("oauth register - application_type", async () => { "file:///callback", "ftp://example.com/callback", "mailto:oauth@example.com", + "ws://host/callback", + "wss://host/callback", "myapp:/callback", "com.example.app:callback", "com.example.app:///callback", @@ -1808,7 +1810,6 @@ describe("oauth register - application_type", async () => { "com.example..app:/callback", "com.example.-app:/callback", "com.example.app-:/callback", - "com.example.app://host/callback", "https://localhost/callback", "https://localhost./callback", "https://localhost../callback", @@ -1835,6 +1836,7 @@ describe("oauth register - application_type", async () => { "http://127.0.0.1:54921/callback", "http://[::1]:61234/callback", "com.example.app:/callback", + "com.example.app://host/callback", "https://app.example.com/callback", ])("accepts valid native redirect URI %s", async (redirectUri) => { const response = await register({ @@ -1858,4 +1860,254 @@ describe("oauth register - application_type", async () => { const body = (await accepted.json()) as OAuthClient; expect(body.application_type).toBe("web"); }); + + /** + * Cursor's MCP OAuth client registers `cursor://…` as a native private-use + * redirect. Host-bearing custom schemes are valid native URIs (RFC 8252 §7.1 + * recommends, but does not require, the authority-free reverse-domain form). + * A `web` registration with the same URI still fails — this is not a + * `web`→`native` coercion. + * + * @see https://github.com/better-auth/better-auth/issues/10946 + */ + it("registers Cursor's host-bearing cursor:// redirect for native clients only", async () => { + const native = await register({ + application_type: "native", + token_endpoint_auth_method: "none", + redirect_uris: ["cursor://anysphere.cursor-mcp/oauth/callback"], + }); + expect(native.status).toBe(201); + const nativeBody = (await native.json()) as OAuthClient; + expect(nativeBody.application_type).toBe("native"); + expect(nativeBody.redirect_uris).toEqual([ + "cursor://anysphere.cursor-mcp/oauth/callback", + ]); + + const web = await register({ + application_type: "web", + redirect_uris: ["cursor://anysphere.cursor-mcp/oauth/callback"], + }); + expect(web.status).toBe(400); + const webBody = (await web.json()) as { error?: string }; + expect(webBody.error).toBe("invalid_redirect_uri"); + }); +}); + +/** + * Current Cursor builds (observed on 3.13.25) OMIT `application_type` in DCR, + * so the OIDC `web` default rejects their `cursor://` redirect before the + * native validator is consulted. `clientRegistrationDefaultApplicationType` + * is server-owned policy for that absent field only — a sent value is never + * overridden. + * + * @see https://github.com/better-auth/better-auth/issues/10946 + */ +describe("oauth register - clientRegistrationDefaultApplicationType", async () => { + const authServerBaseUrl = "http://localhost:3000"; + const makeRegister = (fetchImpl: typeof fetch) => { + return (body: Record) => + fetchImpl(`${authServerBaseUrl}/api/auth/oauth2/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + }; + const instanceWithDefault = async ( + defaultApplicationType: "web" | "native" | "infer" | undefined, + ) => { + const { customFetchImpl } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + allowDynamicClientRegistration: true, + allowUnauthenticatedClientRegistration: true, + clientRegistrationDefaultApplicationType: defaultApplicationType, + }), + ], + }); + return makeRegister(customFetchImpl as typeof fetch); + }; + + it("infers native when a redirect URI uses a custom scheme", async () => { + const register = await instanceWithDefault("infer"); + const response = await register({ + token_endpoint_auth_method: "none", + redirect_uris: ["cursor://anysphere.cursor-mcp/oauth/callback"], + }); + expect(response.status).toBe(201); + const body = (await response.json()) as OAuthClient; + expect(body.application_type).toBe("native"); + expect(body.token_endpoint_auth_method).toBe("none"); + expect(body.redirect_uris).toEqual([ + "cursor://anysphere.cursor-mcp/oauth/callback", + ]); + }); + + it("keeps the web classification under infer when every redirect URI is http(s)", async () => { + const register = await instanceWithDefault("infer"); + const https = await register({ + redirect_uris: ["https://rp.example.com/callback"], + }); + expect(https.status).toBe(201); + const httpsBody = (await https.json()) as OAuthClient; + expect(httpsBody.application_type).toBe("web"); + }); + + it("infers native for Cursor's mixed redirect URI registration", async () => { + const register = await instanceWithDefault("infer"); + const response = await register({ + client_name: "Cursor", + redirect_uris: [ + "cursor://anysphere.cursor-mcp/oauth/callback", + "https://www.cursor.com/agents/mcp/oauth/callback", + "http://localhost:8787/callback", + ], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }); + expect(response.status).toBe(201); + const body = (await response.json()) as OAuthClient; + expect(body.application_type).toBe("native"); + }); + + it("applies native redirect rules to every URI of an inferred native client", async () => { + const register = await instanceWithDefault("infer"); + const response = await register({ + token_endpoint_auth_method: "none", + redirect_uris: [ + "cursor://anysphere.cursor-mcp/oauth/callback", + "http://rp.example.com/callback", + ], + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error?: string }; + expect(body.error).toBe("invalid_redirect_uri"); + }); + + it("never overrides an application_type the client actually sent", async () => { + const register = await instanceWithDefault("infer"); + const response = await register({ + application_type: "web", + token_endpoint_auth_method: "none", + redirect_uris: ["cursor://anysphere.cursor-mcp/oauth/callback"], + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error?: string }; + expect(body.error).toBe("invalid_redirect_uri"); + }); + + it("applies a configured native default to omitted application_type", async () => { + const register = await instanceWithDefault("native"); + const response = await register({ + token_endpoint_auth_method: "none", + redirect_uris: ["cursor://anysphere.cursor-mcp/oauth/callback"], + }); + expect(response.status).toBe(201); + const body = (await response.json()) as OAuthClient; + expect(body.application_type).toBe("native"); + }); + + it("completes authorization and token exchange for an inferred Cursor client", async () => { + const cursorRedirect = "cursor://anysphere.cursor-mcp/oauth/callback"; + const { client, customFetchImpl, signInWithTestUser } = + await getTestInstance( + { + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + allowDynamicClientRegistration: true, + allowUnauthenticatedClientRegistration: true, + clientRegistrationDefaultApplicationType: "infer", + }), + ], + }, + { clientOptions: { plugins: [oauthProviderClient()] } }, + ); + const register = makeRegister(customFetchImpl as typeof fetch); + const registration = await register({ + client_name: "Cursor", + redirect_uris: [ + cursorRedirect, + "https://www.cursor.com/agents/mcp/oauth/callback", + "http://localhost:8787/callback", + ], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }); + expect(registration.status).toBe(201); + const { client_id: clientId } = (await registration.json()) as OAuthClient; + + const { headers } = await signInWithTestUser(); + const codeVerifier = generateRandomString(64); + const authUrl = await createAuthorizationURL({ + id: "cursor", + options: { clientId, redirectURI: cursorRedirect }, + redirectURI: "", + authorizationEndpoint: `${authServerBaseUrl}/api/auth/oauth2/authorize`, + state: "cursor-state", + scopes: ["openid"], + codeVerifier, + }); + let consentRedirectUrl = ""; + await client.$fetch(authUrl.toString(), { + headers, + onError(ctx) { + consentRedirectUrl = ctx.response.headers.get("Location") || ""; + }, + }); + expect(consentRedirectUrl).toContain("/consent"); + + vi.stubGlobal("window", { + location: { + search: new URL(consentRedirectUrl, authServerBaseUrl).search, + }, + }); + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + const consentRes = await client.oauth2.consent( + { accept: true }, + { headers, throw: true }, + ); + const callback = new URL(consentRes.url); + expect(`${callback.protocol}//${callback.host}${callback.pathname}`).toBe( + cursorRedirect, + ); + const code = callback.searchParams.get("code"); + expect(code).toEqual(expect.any(String)); + + const { body: tokenBody, headers: tokenHeaders } = + await authorizationCodeRequest({ + code: code!, + codeVerifier, + redirectURI: cursorRedirect, + options: { clientId, redirectURI: cursorRedirect }, + }); + const tokenRes = await customFetchImpl( + `${authServerBaseUrl}/api/auth/oauth2/token`, + { method: "POST", body: tokenBody.toString(), headers: tokenHeaders }, + ); + expect(tokenRes.status).toBe(200); + const tokens = (await tokenRes.json()) as { access_token?: string }; + expect(tokens.access_token).toEqual(expect.any(String)); + }); + + it("keeps the strict web default when the option is unset", async () => { + const register = await instanceWithDefault(undefined); + const response = await register({ + token_endpoint_auth_method: "none", + redirect_uris: ["cursor://anysphere.cursor-mcp/oauth/callback"], + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error?: string }; + expect(body.error).toBe("invalid_redirect_uri"); + }); }); diff --git a/packages/oauth-provider/src/register.ts b/packages/oauth-provider/src/register.ts index 1a9d4c8017..0ae50804e9 100644 --- a/packages/oauth-provider/src/register.ts +++ b/packages/oauth-provider/src/register.ts @@ -4,7 +4,7 @@ import { runWithTransaction, } from "@better-auth/core/context"; import { isLoopbackIP } from "@better-auth/core/utils/host"; -import { isReverseDomainPrivateUseRedirectUri } from "@better-auth/core/utils/redirect-uri"; +import { isNativePrivateUseRedirectUri } from "@better-auth/core/utils/redirect-uri"; import { APIError, getSessionFromCtx, NO_STORE_HEADERS } from "better-auth/api"; import { generateRandomString } from "better-auth/crypto"; import { toExpJWT } from "better-auth/plugins"; @@ -82,7 +82,7 @@ function resolveRegistrationResponseTypes( function applyOAuthClientRegistrationDefaults( client: OAuthClientRegistrationMetadata, - defaultApplicationType: "web" | null = "web", + defaultApplicationType: "web" | "native" | null = "web", ): OAuthClientRegistrationMetadata { const grantTypes = resolveRegistrationGrantTypes(client); return { @@ -97,14 +97,40 @@ function applyOAuthClientRegistrationDefaults( }; } -const FORBIDDEN_NATIVE_REDIRECT_SCHEMES = new Set([ - "file:", - "ftp:", - "mailto:", - "javascript:", - "data:", - "vbscript:", -]); +function isCustomSchemeWithHost(redirectUri: string): boolean { + const url = new URL(redirectUri); + return ( + url.protocol !== "http:" && url.protocol !== "https:" && url.host !== "" + ); +} + +function isNonHttpRedirectUri(redirectUri: string): boolean { + try { + const protocol = new URL(redirectUri).protocol; + return protocol !== "http:" && protocol !== "https:"; + } catch { + return false; + } +} + +/** + * 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. + */ +function resolveDynamicRegistrationDefaultApplicationType( + opts: OAuthOptions, + client: OAuthClientRegistrationMetadata, +): "web" | "native" { + const configured = opts.clientRegistrationDefaultApplicationType ?? "web"; + if (configured !== "infer") return configured; + return (client.redirect_uris ?? []).some(isNonHttpRedirectUri) + ? "native" + : "web"; +} function invalidRedirectUri(description: string): never { throw new APIError("BAD_REQUEST", { @@ -225,12 +251,9 @@ function validateClientRedirectUri( return; } - if ( - FORBIDDEN_NATIVE_REDIRECT_SCHEMES.has(url.protocol) || - !isReverseDomainPrivateUseRedirectUri(url) - ) { + if (!isNativePrivateUseRedirectUri(url)) { invalidRedirectUri( - `native private-use redirect URI schemes must be well-formed reverse-domain names, omit the naming authority, and must not use a reserved scheme: ${redirectUri}`, + `native private-use redirect URI schemes must not use a reserved scheme; they must be an authority-free reverse-domain URI or a custom-scheme URI with a host and a path: ${redirectUri}`, ); } } @@ -345,7 +368,11 @@ export async function checkOAuthClient( settings?.registrationSource === "clientMetadataDocument"; const clientWithDefaults = applyOAuthClientRegistrationDefaults( client, - isClientMetadataDocument ? null : "web", + isClientMetadataDocument + ? null + : settings?.registrationSource === "dynamic" + ? resolveDynamicRegistrationDefaultApplicationType(opts, client) + : "web", ); const tokenEndpointAuthMethod = clientWithDefaults.token_endpoint_auth_method ?? "client_secret_basic"; @@ -399,7 +426,8 @@ export async function checkOAuthClient( // A CIMD document may omit application_type. Preserve that absence in // storage while validating against the safe union of web and native // redirect forms. The native validator is that union: non-loopback HTTPS, - // exact loopback HTTP, or an authority-free private-use scheme. + // exact loopback HTTP, an authority-free reverse-domain private-use + // scheme, or a host-bearing non-reserved custom scheme. validateClientRedirectUri( uri, (applicationType as "web" | "native" | undefined) ?? @@ -471,6 +499,19 @@ export async function checkOAuthClient( "pairwise subject_type requires server pairwiseSecret configuration", }); } + // The pairwise sector is the redirect URI host. Any app can register a + // custom scheme, so the host in `app://rp.example.com/callback` does not + // identify the client and would share an HTTPS client's sector. + if ( + clientWithDefaults.subject_type === "pairwise" && + clientWithDefaults.redirect_uris?.some(isCustomSchemeWithHost) + ) { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: + "pairwise clients cannot use custom-scheme redirect URIs that include a host", + }); + } // Per OIDC Core §8.1, when multiple redirect_uris have different hosts, // a sector_identifier_uri is required (not yet supported). Reject registration // until sector_identifier_uri support is added. @@ -760,7 +801,14 @@ async function persistOAuthClientRegistration( : input.metadata; const body = applyOAuthClientRegistrationDefaults( registrationMetadata, - input.registrationSource === "clientMetadataDocument" ? null : "web", + input.registrationSource === "clientMetadataDocument" + ? null + : input.registrationSource === "dynamic" + ? resolveDynamicRegistrationDefaultApplicationType( + opts, + registrationMetadata, + ) + : "web", ); // Determine whether registration request for public client diff --git a/packages/oauth-provider/src/types/index.ts b/packages/oauth-provider/src/types/index.ts index 96daa99ab4..28e0787ac6 100644 --- a/packages/oauth-provider/src/types/index.ts +++ b/packages/oauth-provider/src/types/index.ts @@ -864,6 +864,27 @@ export interface OAuthOptions< * @default true */ clientRegistrationRequirePKCE?: boolean; + /** + * Default `application_type` for dynamic client registrations that omit the + * field. + * + * - `"web"`: the OIDC Registration §2 default. Omitted `application_type` is + * treated as `web`, so custom-scheme 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. + * + * An `application_type` the client actually sends is never overridden. + * Applies only to dynamic client registration; managed clients and Client ID + * Metadata Documents are unaffected. + * + * @default "web" + */ + clientRegistrationDefaultApplicationType?: "web" | "native" | "infer"; /** * How long a dynamically created confidential client * should last for.