mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-20 20:26:55 -05:00
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:
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user