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