feat(oauth-provider): apply application_type to metadata document clients

Client ID Metadata Documents are the registration mechanism the MCP
2026-07-28 spec recommends, and dynamic client registration the one it
deprecates, but the `application_type` redirect URI constraint reached only
the deprecated path: the field was dropped by the metadata document
allowlist before validation ran.

`type` and `application_type` both describe the client profile, so a
registration sending both must keep them consistent. Cite RFC 8252 for the
native redirect URI rule actually enforced; OIDC Registration predates
claimed https links and does not permit them.
This commit is contained in:
Gustavo Valverde
2026-07-28 19:16:41 -04:00
parent 39d132e834
commit 8a5046b6d5
8 changed files with 117 additions and 14 deletions
+4 -1
View File
@@ -1,5 +1,8 @@
---
"@better-auth/oauth-provider": minor
"@better-auth/cimd": 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.
Client registration accepts `application_type` (OIDC Registration §2), which the MCP 2026-07-28 spec requires MCP clients to send. When provided, redirect URIs are constrained to what the declared type allows: `native` clients may use custom schemes, loopback `http`, or claimed `https` links; `web` clients require `https` on non-loopback hosts. Omitting the parameter leaves redirect URIs unconstrained.
The constraint applies to both registration paths: dynamic client registration and Client ID Metadata Documents, which the same spec revision makes the preferred mechanism. A registration that sends both `application_type` and `type` must keep them consistent, so a client record cannot claim two different profiles.
+3 -1
View File
@@ -448,7 +448,9 @@ 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.
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. A `native` client may use custom schemes, loopback `http` redirects, or claimed `https` links ([RFC 8252 §7](https://datatracker.ietf.org/doc/html/rfc8252#section-7)); plain `http` on a routable host is rejected. A `web` client requires `https` redirect URIs on non-loopback hosts. Sending both `application_type` and `type` requires them to agree. When `application_type` is omitted, no constraints are applied.
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`. The same constraints apply to clients identified by a [Client ID Metadata Document](/docs/plugins/cimd).
Note the following parameters are not yet supported:
+64
View File
@@ -274,4 +274,68 @@ describe("Client ID Metadata Document - integration", async () => {
});
expect(errorStatus).toBeGreaterThanOrEqual(400);
});
it("enforces application_type redirect URI constraints from the metadata document", async ({
onTestFinished,
}) => {
const originalFetch = globalThis.fetch.bind(globalThis);
const nativeClientUrl = "https://native.example.com/client-metadata.json";
const loopbackRedirect = "http://127.0.0.1:5099/callback";
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
if (url === nativeClientUrl) {
return Promise.resolve(
new Response(
JSON.stringify({
client_id: nativeClientUrl,
client_name: "Mislabelled Native Client",
// A loopback interception redirect belongs to a native
// client; declaring `web` contradicts it.
application_type: "web",
redirect_uris: [loopbackRedirect],
token_endpoint_auth_method: "none",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
}
return originalFetch(input, init);
}),
);
onTestFinished(() => {
vi.unstubAllGlobals();
});
const { headers } = await signInWithTestUser();
const authedClient = createAuthClient({
plugins: [oauthProviderClient()],
baseURL: authServerBaseUrl,
fetchOptions: { customFetchImpl, headers },
});
const authorizeUrl =
`${authServerBaseUrl}/api/auth/oauth2/authorize` +
`?client_id=${encodeURIComponent(nativeClientUrl)}` +
`&response_type=code` +
`&redirect_uri=${encodeURIComponent(loopbackRedirect)}` +
`&scope=openid` +
`&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM` +
`&code_challenge_method=S256`;
let errorStatus = 0;
await authedClient.$fetch(authorizeUrl, {
method: "GET",
onError(ctx) {
errorStatus = ctx.response.status;
},
});
expect(errorStatus).toBeGreaterThanOrEqual(400);
});
});
+1
View File
@@ -98,6 +98,7 @@ const ALLOWED_METADATA_FIELDS = new Set([
"software_statement",
"post_logout_redirect_uris",
"subject_type",
"application_type",
"type",
"jwks",
"jwks_uri",
@@ -1507,6 +1507,27 @@ describe("oauth register - application_type", async () => {
expect(body.application_type).toBe("web");
});
it("rejects an application_type that contradicts type", async () => {
const response = await register({
type: "web",
application_type: "native",
redirect_uris: ["https://rp.example.com/callback"],
});
expect(response.status).toBe(400);
const body = (await response.json()) as { error_description?: string };
expect(body.error_description).toContain("conflicts with type");
});
it("accepts a user-agent-based client declaring the web application type", async () => {
const response = await register({
type: "user-agent-based",
application_type: "web",
token_endpoint_auth_method: "none",
redirect_uris: ["https://spa.example.com/callback"],
});
expect(response.status).toBe(201);
});
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
+20 -6
View File
@@ -295,10 +295,24 @@ 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.
// `type` and `application_type` both describe the client profile, so a
// registration that sends both must not contradict itself.
if (clientWithDefaults.type && clientWithDefaults.application_type) {
const impliedApplicationType =
clientWithDefaults.type === "native" ? "native" : "web";
if (impliedApplicationType !== clientWithDefaults.application_type) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description: `application_type '${clientWithDefaults.application_type}' conflicts with type '${clientWithDefaults.type}'`,
});
}
}
// OIDC Registration §2 `application_type`, required of MCP clients by
// SEP-837. Native redirect URIs follow RFC 8252 §7 (custom scheme, loopback
// http, or claimed https link) rather than OIDC's narrower list, which
// predates claimed https links. Only enforced when the parameter is
// explicit, so registrations that omit it keep working.
if (clientWithDefaults.application_type) {
for (const uri of clientWithDefaults.redirect_uris ?? []) {
const url = new URL(uri);
@@ -307,8 +321,8 @@ export async function checkOAuthClient(
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.
// 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",
+2 -3
View File
@@ -390,9 +390,8 @@ export interface OAuthClient {
/**
* 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.
* redirects, or claimed `https` links (RFC 8252 §7); `web` clients require
* `https` on a non-loopback host. Must agree with `type` when both are sent.
*
* @default "web"
* @see https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata
+2 -3
View File
@@ -207,9 +207,8 @@ 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.
// OIDC Registration §2: constrains redirect URI schemes and hosts when
// provided, and must agree with `type` when both are sent.
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.