mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-18 11:32:15 -05:00
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:
co-authored by
Gustavo Valverde
parent
07a09389ef
commit
62146108c7
@@ -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.
|
||||
@@ -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>",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user