fix(oauth-provider): accept host-bearing custom-scheme native redirects (#10956)

Co-authored-by: Gustavo Valverde <g.valverde02@gmail.com>
This commit is contained in:
Don Isbell
2026-10-09 08:59:37 +01:00
committed by GitHub
co-authored by Gustavo Valverde
parent 07a09389ef
commit 62146108c7
11 changed files with 619 additions and 32 deletions
@@ -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.
+1 -1
View File
@@ -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<boolean>",
+32 -1
View File
@@ -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
})
```
<Callout type="warn">
[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).
</Callout>
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.
</Callout>
@@ -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,
@@ -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,
+49
View File
@@ -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.
+39
View File
@@ -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);
});
});
@@ -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(() =>
+253 -1
View File
@@ -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<string, unknown>) =>
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");
});
});
+66 -18
View File
@@ -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<Scope[]>,
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
@@ -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.