feat(oauth-provider): accept application_type in dynamic client registration

OIDC Registration §2 / MCP 2026-07-28 (SEP-837): when a registration
declares application_type, redirect URIs are constrained to what the
declared type allows — native clients may use custom schemes, loopback
http redirects, or claimed https links, while web clients require https
on non-loopback hosts. Only enforced when the parameter is explicit, so
existing registrations keep working. The field round-trips through
client metadata and is echoed in the registration response.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbU1ZgRHK5A1btqpdR5gbD
This commit is contained in:
Claude
2026-07-28 19:31:29 +00:00
parent 0d1ba97145
commit 8e8a74f73f
6 changed files with 147 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@better-auth/oauth-provider": minor
---
Accept `application_type` (OIDC Registration §2, required of MCP clients by the MCP 2026-07-28 spec) in dynamic client registration. When provided, redirect URIs are constrained to the declared type: `native` clients may use custom schemes, loopback `http`, or `https`; `web` clients require `https` on non-loopback hosts. Omitting the parameter keeps prior behavior.
+3 -1
View File
@@ -422,7 +422,7 @@ oauthProvider({
To enable open client registration without a Better Auth session, additionally set `allowUnauthenticatedClientRegistration: true` in your auth config. Public clients are registered with `token_endpoint_auth_method: "none"`. Confidential clients receive a one-time `client_secret` in the registration response.
<Callout type="info">
For MCP public-client identity, [Client ID Metadata Documents](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/991) are [available](/docs/plugins/cimd).
For MCP public-client identity, use [Client ID Metadata Documents](/docs/plugins/cimd). The [MCP 2026-07-28 spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration) deprecates Dynamic Client Registration in favor of CIMD; DCR remains supported for backwards compatibility.
</Callout>
```ts title="auth.ts"
@@ -448,6 +448,8 @@ const client = await authClient.oauth2.register({
For all endpoint parameters, see [RFC 7591 Registration](https://datatracker.ietf.org/doc/html/rfc7591#section-2).
When `application_type` ([OIDC Registration §2](https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata)) is provided, redirect URIs are constrained to what the declared type allows: `native` clients may use custom schemes, loopback `http` redirects, or claimed `https` links — plain `http` on a routable host is rejected; `web` clients require `https` redirect URIs on non-loopback hosts. MCP clients are required to send an appropriate `application_type` by the [MCP 2026-07-28 spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#application-type-and-redirect-uri-constraints). When omitted, no `application_type` constraints are applied.
Note the following parameters are not yet supported:
* `sector_identifier_uri`
@@ -1424,3 +1424,98 @@ describe("oauth register - protected dynamic registration", async () => {
expect(tokenBody.user_id).toBeUndefined();
});
});
describe("oauth register - application_type", async () => {
const authServerBaseUrl = "http://localhost:3000";
const { customFetchImpl } = await getTestInstance({
baseURL: authServerBaseUrl,
plugins: [
jwt(),
oauthProvider({
loginPage: "/login",
consentPage: "/consent",
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
silenceWarnings: {
oauthAuthServerConfig: true,
openidConfig: true,
},
}),
],
});
const register = (body: Record<string, unknown>) =>
customFetchImpl(`${authServerBaseUrl}/api/auth/oauth2/register`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
it("registers a native client with loopback and custom-scheme redirect URIs", async () => {
const response = await register({
application_type: "native",
token_endpoint_auth_method: "none",
redirect_uris: [
"http://localhost:3005/callback",
"http://127.0.0.1:3005/callback",
"com.example.app:/callback",
],
});
expect(response.status).toBe(201);
const body = (await response.json()) as OAuthClient;
expect(body.application_type).toBe("native");
});
it("rejects a native client with an http redirect URI on a routable host", async () => {
const response = await register({
application_type: "native",
token_endpoint_auth_method: "none",
redirect_uris: ["http://example.com/callback"],
});
expect(response.status).toBe(400);
const body = (await response.json()) as { error?: string };
expect(body.error).toBe("invalid_redirect_uri");
});
it("rejects a web client with an http redirect URI", async () => {
const response = await register({
application_type: "web",
redirect_uris: ["http://example.com/callback"],
});
expect(response.status).toBe(400);
const body = (await response.json()) as { error?: string };
expect(body.error).toBe("invalid_redirect_uri");
});
it("rejects a web client with a loopback redirect URI", async () => {
const response = await register({
application_type: "web",
redirect_uris: ["https://localhost:3005/callback"],
});
expect(response.status).toBe(400);
const body = (await response.json()) as { error?: string };
expect(body.error).toBe("invalid_redirect_uri");
});
it("registers a web client with an https redirect URI", async () => {
const response = await register({
application_type: "web",
redirect_uris: ["https://rp.example.com/callback"],
});
expect(response.status).toBe(201);
const body = (await response.json()) as OAuthClient;
expect(body.application_type).toBe("web");
});
it("keeps prior behavior when application_type is omitted", async () => {
// OIDC Registration defaults application_type to "web", but enforcing
// that default would break existing localhost integrations; constraints
// only apply when the parameter is sent explicitly.
const response = await register({
redirect_uris: ["http://localhost:5000/api/auth/callback/test"],
});
expect(response.status).toBe(201);
const body = (await response.json()) as OAuthClient;
expect(body.application_type).toBeUndefined();
});
});
+29
View File
@@ -295,6 +295,35 @@ export async function checkOAuthClient(
});
}
// OIDC Registration §2 / MCP SEP-837: when `application_type` is sent,
// constrain redirect URIs to what the declared type allows. Only enforced
// when the parameter is explicit, so registrations predating this check
// keep working.
if (clientWithDefaults.application_type) {
for (const uri of clientWithDefaults.redirect_uris ?? []) {
const url = new URL(uri);
const isHttp = url.protocol === "http:";
const isHttps = url.protocol === "https:";
const isLoopback = (isHttp || isHttps) && isLoopbackHost(url.hostname);
if (clientWithDefaults.application_type === "native") {
// Native clients redirect to custom schemes, loopback http
// interception servers, or claimed https links — never plain
// http on a routable host.
if (isHttp && !isLoopback) {
throw new APIError("BAD_REQUEST", {
error: "invalid_redirect_uri",
error_description: `native clients must not use http redirect URIs on non-loopback hosts: ${uri}`,
});
}
} else if (!isHttps || isLoopback) {
throw new APIError("BAD_REQUEST", {
error: "invalid_redirect_uri",
error_description: `web clients require https redirect URIs on non-loopback hosts: ${uri}`,
});
}
}
}
// Validate correlation between grant_types and response_types
const supportedGrantTypes = new Set(getSupportedGrantTypes(opts));
for (const grantType of grantTypes) {
@@ -387,6 +387,17 @@ export interface OAuthClient {
grant_types?: GrantType[];
response_types?: "code"[];
// | "token" // NEVER SUPPORT - deprecated in OAuth 2.1
/**
* OIDC Registration application type, constraining redirect URI schemes and
* hosts when provided: `native` clients use custom schemes, loopback `http`
* redirects, or claimed `https` links; `web` clients require `https` on a
* non-loopback host. Distinct from `type`, which tracks the RFC 6749 client
* profile.
*
* @default "web"
* @see https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata
*/
application_type?: "web" | "native";
//---- RFC6749 Spec ----//
public?: boolean;
type?: "web" | "native" | "user-agent-based";
+4
View File
@@ -207,6 +207,10 @@ export const clientRegistrationRequestSchema = z.object({
grant_types: z.array(z.string().trim().min(1)).min(1).optional(),
response_types: z.array(z.enum(["code"])).optional(),
type: z.enum(["web", "native", "user-agent-based"]).optional(),
// OIDC Registration §2 / MCP SEP-837: constrains redirect URI schemes and
// hosts when provided. Distinct from `type`, which tracks the RFC 6749
// client profile.
application_type: z.enum(["web", "native"]).optional(),
subject_type: z.enum(["public", "pairwise"]).optional(),
// RFC 9449 §5.2: client asks for DPoP-bound access tokens.
dpop_bound_access_tokens: z.boolean().optional(),