refactor(oauth-provider)!: replace client type with application_type

Client registration carried two overlapping profile fields. `type`
(web/native/user-agent-based) was a better-auth invention with no RFC 7591
standing, validated only for self-consistency against the auth method;
`application_type` is the OIDC Registration field, and the one MCP clients
are told to send.

Registration accepted both without cross-checking, so a client could declare
`type: "web"` alongside `application_type: "native"` and be stored claiming
two profiles. Collapsing to `application_type` removes the ambiguity and
gives the field a real column rather than the untyped metadata bag.

`user-agent-based` has no successor: a browser app registers with
`token_endpoint_auth_method: "none"`, which already marks it public. PKCE
enforcement reads `application_type` for the native case that `type` used to
cover, so a native client stays public whatever auth method it registered
with.
This commit is contained in:
Gustavo Valverde
2026-07-28 19:36:09 -04:00
parent 8a5046b6d5
commit 74cdffddde
15 changed files with 82 additions and 143 deletions
@@ -0,0 +1,10 @@
---
"@better-auth/oauth-provider": minor
"@better-auth/cimd": minor
---
OAuth clients describe their profile with one field, `application_type`, instead of two overlapping ones. The non-standard `type` field is removed, and its `web` and `native` values move to `application_type`; `user-agent-based` has no replacement, since a browser app registers with `token_endpoint_auth_method: "none"`, which already marks it public.
This release is breaking. Registration requests and the client management APIs no longer accept `type`, and client responses no longer return it. Replace `type: "web"` with `application_type: "web"`, `type: "native"` with `application_type: "native"`, and drop `type: "user-agent-based"`. A `native` client is now always treated as public, so PKCE is required for it even when the client registered with `require_pkce: false`.
The client table gains an `applicationType` column and no longer uses `type`. Run a migration after upgrading with `npx auth migrate` or `npx auth generate`. Existing clients keep working; those that had set `type` are treated as if no application type was declared, which leaves their redirect URIs unconstrained.
@@ -147,7 +147,7 @@ await auth.api.adminCreateOAuthClient({
headers,
body: {
token_endpoint_auth_method: "none",
type: "native",
application_type: "native",
grant_types: [DEVICE_CODE_GRANT_TYPE, "refresh_token"],
scope: "openid profile offline_access api:read",
resources: ["https://api.example.com"],
+3 -3
View File
@@ -448,7 +448,7 @@ 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. 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.
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, and is the only type that may authenticate at the token endpoint with a secret; a `native` client is always treated as public, so PKCE is required for it. 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).
@@ -2005,10 +2005,10 @@ export const oauthClientTableFields = [
isOptional: true,
},
{
name: "type",
name: "application_type",
type: "string",
description:
"Type of OAuth client. Supports: ['web', 'native', 'user-agent-based']",
"OIDC Registration application type. Supports: ['web', 'native']. Native clients are always treated as public.",
isOptional: true,
},
{
-1
View File
@@ -99,7 +99,6 @@ const ALLOWED_METADATA_FIELDS = new Set([
"post_logout_redirect_uris",
"subject_type",
"application_type",
"type",
"jwks",
"jwks_uri",
]);
@@ -61,7 +61,6 @@ describe("oauth-provider device-code grant", async () => {
token_endpoint_auth_method: "none",
grant_types: grantTypes,
scope: "openid profile email",
type: "native",
},
});
return created!.client_id;
@@ -353,7 +352,6 @@ describe("oauth-provider device-code grant", async () => {
token_endpoint_auth_method: "none",
grant_types: [DEVICE_CODE_GRANT_TYPE],
scope: "openid",
type: "native",
},
});
@@ -380,7 +378,6 @@ describe("oauth-provider device-code grant", async () => {
token_endpoint_auth_method: "client_secret_basic",
grant_types: [DEVICE_CODE_GRANT_TYPE],
scope: "openid",
type: "web",
},
});
@@ -480,7 +477,6 @@ describe("oauth-provider device-code grant expiry", async () => {
token_endpoint_auth_method: "none",
grant_types: [DEVICE_CODE_GRANT_TYPE],
scope: "openid",
type: "native",
},
});
const clientId = created!.client_id;
@@ -273,7 +273,6 @@ describe("oauth-provider extensions", async () => {
token_endpoint_auth_method: extensionAuthMethod,
grant_types: [extensionGrant],
scope: "openid email vc",
type: "web",
},
});
expect(adminClient?.client_id).toBeDefined();
@@ -289,7 +288,6 @@ describe("oauth-provider extensions", async () => {
token_endpoint_auth_method: extensionAuthMethod,
grant_types: [extensionGrant],
scope: "openid email vc",
type: "web",
},
});
expect(registeredClient?.client_id).toBeDefined();
@@ -311,7 +309,6 @@ describe("oauth-provider extensions", async () => {
grant_types: [extensionGrant],
response_types: ["code"],
scope: "openid email vc",
type: "web",
},
});
} catch (e) {
@@ -336,7 +333,6 @@ describe("oauth-provider extensions", async () => {
grant_types: [extensionGrant],
jwks_uri: "https://127.0.0.1/jwks",
scope: "openid email vc",
type: "web",
},
});
} catch (e) {
@@ -362,7 +358,6 @@ describe("oauth-provider extensions", async () => {
jwks: [{ kty: "RSA", n: "test", e: "test-exponent" }],
jwks_uri: "https://client.example.com/jwks",
scope: "openid email vc",
type: "web",
},
});
} catch (e) {
@@ -397,7 +392,6 @@ describe("oauth-provider extensions", async () => {
body: {
grant_types: [extensionGrant],
scope: "openid email vc",
type: "web",
},
});
const response = await client.$fetch("/oauth2/token", {
@@ -496,7 +490,6 @@ describe("oauth-provider extensions", async () => {
token_endpoint_auth_method: extensionAuthMethod,
grant_types: [extensionGrant],
scope: "openid email vc",
type: "web",
},
});
expect(oauthClient?.client_id).toBeDefined();
@@ -608,7 +601,6 @@ describe("oauth-provider extensions", async () => {
token_endpoint_auth_method: extensionAuthMethod,
grant_types: [extensionOpaqueGrant],
scope: "openid email vc",
type: "web",
},
});
const response = await client.$fetch("/oauth2/token", {
@@ -975,7 +967,6 @@ describe("oauth-provider extensions", async () => {
token_endpoint_auth_method: extensionAuthMethod,
grant_types: [extensionOpaqueGrant],
scope: "openid email vc",
type: "web",
},
});
const tokenResponse = await client.$fetch<{ access_token: string }>(
@@ -1025,7 +1016,6 @@ describe("oauth-provider extensions", async () => {
token_endpoint_auth_method: extensionAuthMethod,
grant_types: [extensionGrant],
scope: "openid email vc",
type: "web",
},
});
const response = await client.$fetch("/oauth2/token", {
@@ -1052,7 +1042,6 @@ describe("oauth-provider extensions", async () => {
token_endpoint_auth_method: extensionAuthMethod,
grant_types: [extensionOpaqueGrant],
scope: "openid email vc",
type: "web",
},
});
const tokenResponse = await client.$fetch<{ access_token: string }>(
@@ -1134,7 +1123,6 @@ describe("oauth-provider extensions", async () => {
token_endpoint_auth_method: extensionAuthMethod,
grant_types: [extensionOpaqueBoundGrant],
scope: "openid email vc",
type: "web",
},
});
const clientAuth = {
+4 -4
View File
@@ -1598,12 +1598,12 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
public: {
type: "boolean",
description:
"Whether the client is public as determined by the type",
"Whether the client is a public client",
},
type: {
application_type: {
type: "string",
description: "Type of the client",
enum: ["web", "native", "user-agent-based"],
description: "OIDC Registration application type",
enum: ["web", "native"],
},
disabled: {
type: "boolean",
@@ -48,7 +48,7 @@ export const adminCreateOAuthClient = (opts: OAuthOptions<Scope[]>) =>
jwks_uri: z.string().optional(),
grant_types: grantTypesSchema.optional(),
response_types: z.array(z.enum(["code"])).optional(),
type: z.enum(["web", "native", "user-agent-based"]).optional(),
application_type: z.enum(["web", "native"]).optional(),
// SERVER_ONLY applicable fields
client_secret_expires_at: z
.union([z.string(), z.number()])
@@ -177,13 +177,12 @@ export const adminCreateOAuthClient = (opts: OAuthOptions<Scope[]>) =>
},
public: {
type: "boolean",
description:
"Whether the client is public as determined by the type",
description: "Whether the client is a public client",
},
type: {
application_type: {
type: "string",
description: "Type of the client",
enum: ["web", "native", "user-agent-based"],
description: "OIDC Registration application type",
enum: ["web", "native"],
},
disabled: {
type: "boolean",
@@ -250,7 +249,7 @@ export const createOAuthClient = (opts: OAuthOptions<Scope[]>) =>
jwks_uri: z.string().optional(),
grant_types: grantTypesSchema.optional(),
response_types: z.array(z.enum(["code"])).optional(),
type: z.enum(["web", "native", "user-agent-based"]).optional(),
application_type: z.enum(["web", "native"]).optional(),
// RFC 9449 §5.2: client asks for DPoP-bound access tokens.
dpop_bound_access_tokens: z.boolean().optional(),
}),
@@ -368,13 +367,12 @@ export const createOAuthClient = (opts: OAuthOptions<Scope[]>) =>
},
public: {
type: "boolean",
description:
"Whether the client is public as determined by the type",
description: "Whether the client is a public client",
},
type: {
application_type: {
type: "string",
description: "Type of the client",
enum: ["web", "native", "user-agent-based"],
description: "OIDC Registration application type",
enum: ["web", "native"],
},
disabled: {
type: "boolean",
@@ -509,7 +507,7 @@ export const adminUpdateOAuthClient = (opts: OAuthOptions<Scope[]>) =>
// NOTE: token_endpoint_auth_method is currently immutable since it changes isPublic definition
grant_types: grantTypesSchema.optional(),
response_types: z.array(z.enum(["code"])).optional(),
type: z.enum(["web", "native", "user-agent-based"]).optional(),
application_type: z.enum(["web", "native"]).optional(),
// SERVER_ONLY applicable fields
client_secret_expires_at: z
.union([z.string(), z.number()])
@@ -559,7 +557,7 @@ export const updateOAuthClient = (opts: OAuthOptions<Scope[]>) =>
// NOTE: token_endpoint_auth_method is currently immutable since it changes isPublic definition
grant_types: grantTypesSchema.optional(),
response_types: z.array(z.enum(["code"])).optional(),
type: z.enum(["web", "native", "user-agent-based"]).optional(),
application_type: z.enum(["web", "native"]).optional(),
}),
}),
metadata: {
+40 -53
View File
@@ -110,37 +110,12 @@ describe("oauth register", async () => {
expect(response.error?.status).toBe(400);
});
it("should fail type check for public client request", async () => {
const response = await serverClient.oauth2.register({
token_endpoint_auth_method: "none",
type: "web",
redirect_uris: [redirectUri],
});
expect(response.error?.status).toBe(400);
});
it.for([
"native",
"user-agent-based",
] as OAuthClient["type"][])("should fail with type '%s' check for confidential client request", async (type) => {
const response = await serverClient.oauth2.register({
token_endpoint_auth_method: "client_secret_post",
type,
redirect_uris: [redirectUri],
});
expect(response.error?.status).toBe(400);
});
it.for([
"native",
"user-agent-based",
] as OAuthClient["type"][])("should register public '%s' client with minimum requirements via server", async (type) => {
it("should register public client with minimum requirements via server", async () => {
const response = await auth.api.adminCreateOAuthClient({
headers,
body: {
token_endpoint_auth_method: "none",
redirect_uris: [redirectUri],
type,
},
});
expect(response?.client_id).toBeDefined();
@@ -182,7 +157,6 @@ describe("oauth register", async () => {
response_types: ["code"],
//---- RFC6749 Spec ----//
public: true, // test never set on this (based off of token_endpoint_auth_method)
type: "web",
//---- Not Part of RFC7591 Spec ----//
disabled: false,
};
@@ -234,10 +208,9 @@ describe("oauth register", async () => {
expect(response.data?.disabled).toBeFalsy();
});
it("should preserve confidential method and type for authenticated registration", async () => {
it("should preserve the confidential auth method for authenticated registration", async () => {
const response = await serverClient.oauth2.register({
token_endpoint_auth_method: "client_secret_post",
type: "web",
redirect_uris: [redirectUri],
});
expect(response.data?.client_id).toBeDefined();
@@ -245,7 +218,6 @@ describe("oauth register", async () => {
expect(response.data?.token_endpoint_auth_method).toBe(
"client_secret_post",
);
expect(response.data?.type).toBe("web");
expect(response.data?.public).toBeFalsy();
});
@@ -480,7 +452,6 @@ describe("oauth register", async () => {
const response = await serverClient.oauth2.register({
redirect_uris: [redirectUri],
token_endpoint_auth_method: "none",
type: "native",
backchannel_logout_uri: `${rpBaseUrl}/logout/backchannel`,
});
expect(response.data?.client_id).toBeDefined();
@@ -645,10 +616,9 @@ describe("oauth register - unauthenticated", async () => {
/**
* @see https://github.com/better-auth/better-auth/issues/8588
*/
it("should preserve type 'web' for unauthenticated confidential DCR", async () => {
it("should keep unauthenticated confidential DCR confidential", async () => {
const response = await unauthenticatedClient.oauth2.register({
token_endpoint_auth_method: "client_secret_post",
type: "web",
redirect_uris: [redirectUri],
});
expect(response.data?.client_id).toBeDefined();
@@ -656,7 +626,7 @@ describe("oauth register - unauthenticated", async () => {
expect(response.data?.token_endpoint_auth_method).toBe(
"client_secret_post",
);
expect(response.data?.type).toBe("web");
expect(response.data?.public).toBeFalsy();
});
/**
@@ -1427,7 +1397,7 @@ describe("oauth register - protected dynamic registration", async () => {
describe("oauth register - application_type", async () => {
const authServerBaseUrl = "http://localhost:3000";
const { customFetchImpl } = await getTestInstance({
const { auth, signInWithTestUser, customFetchImpl } = await getTestInstance({
baseURL: authServerBaseUrl,
plugins: [
jwt(),
@@ -1507,25 +1477,42 @@ 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("forces PKCE on a native client even when the client opts out", async () => {
// A native client cannot keep a secret, so it is public whatever auth
// method it registered with, and `require_pkce: false` cannot waive PKCE
// for it. The same client declared `web` is taken at its word.
const { headers } = await signInWithTestUser();
const authorizeWithoutPkce = async (
applicationType: "web" | "native",
redirectUri: string,
) => {
const created = await auth.api.adminCreateOAuthClient({
headers,
body: {
application_type: applicationType,
token_endpoint_auth_method: "client_secret_post",
require_pkce: false,
redirect_uris: [redirectUri],
},
});
const response = await customFetchImpl(
`${authServerBaseUrl}/api/auth/oauth2/authorize?${new URLSearchParams({
client_id: created!.client_id!,
response_type: "code",
redirect_uri: redirectUri,
scope: "openid",
})}`,
{ method: "GET", redirect: "manual" },
);
return response.headers.get("location") ?? "";
};
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);
expect(
await authorizeWithoutPkce("native", "com.example.pkce:/cb"),
).toContain("pkce+is+required");
expect(
await authorizeWithoutPkce("web", "https://rp.example.com/pkce-cb"),
).not.toContain("pkce+is+required");
});
it("keeps prior behavior when application_type is omitted", async () => {
+4 -38
View File
@@ -258,27 +258,6 @@ export async function checkOAuthClient(
});
}
// Check value of type, if sent, matches isPublic
if (clientWithDefaults.type) {
if (
isPublic &&
!(
clientWithDefaults.type === "native" ||
clientWithDefaults.type === "user-agent-based"
)
) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description: `Type must be 'native' or 'user-agent-based' for public applications`,
});
} else if (!isPublic && !(clientWithDefaults.type === "web")) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description: `Type must be 'web' for confidential applications`,
});
}
}
const grantTypes = clientWithDefaults.grant_types ?? [];
const responseTypes = clientWithDefaults.response_types;
@@ -295,19 +274,6 @@ export async function checkOAuthClient(
});
}
// `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
@@ -765,7 +731,7 @@ export function oauthToSchema(input: OAuthClient): SchemaClient<Scope[]> {
response_types: responseTypes,
// RFC6749 Spec
public: _public,
type,
application_type: applicationType,
// Not Part of RFC7591 Spec
disabled,
skip_consent: skipConsent,
@@ -833,7 +799,7 @@ export function oauthToSchema(input: OAuthClient): SchemaClient<Scope[]> {
jwksUri: jwksUri,
// RFC6749 Spec
public: _public,
type,
applicationType,
// All other metadata
skipConsent,
enableEndSession,
@@ -884,7 +850,7 @@ export function schemaToOAuth(input: SchemaClient<Scope[]>): OAuthClient {
responseTypes,
// RFC6749 Spec
public: _public,
type,
applicationType,
// Jwks
jwks,
jwksUri,
@@ -946,7 +912,7 @@ export function schemaToOAuth(input: SchemaClient<Scope[]>): OAuthClient {
response_types: responseTypes ?? undefined,
// RFC6749 Spec
public: _public ?? undefined,
type: type ?? undefined,
application_type: applicationType ?? undefined,
// Not Part of RFC7591 Spec
disabled: disabled ?? undefined,
skip_consent: skipConsent ?? undefined,
+1 -1
View File
@@ -133,7 +133,7 @@ export const schema = {
type: "boolean",
required: false,
},
type: {
applicationType: {
type: "string",
required: false,
},
+5 -6
View File
@@ -1712,15 +1712,14 @@ export interface SchemaClient<
*/
public?: boolean;
/**
* The client type
* OIDC Registration application type.
*
* Described https://www.rfc-editor.org/rfc/rfc6749.html#section-2.1
* Constrains which redirect URIs the client may register, and marks
* `native` clients as public so PKCE is always required for them.
*
* - web - A web application (confidential client)
* - native - A mobile application (public client)
* - user-agent-based - A user-agent-based application (public client)
* @see https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata
*/
type?: "web" | "native" | "user-agent-based";
applicationType?: "web" | "native";
/**
* Whether this client requires PKCE for authorization code flow.
*
@@ -399,7 +399,6 @@ export interface OAuthClient {
application_type?: "web" | "native";
//---- RFC6749 Spec ----//
public?: boolean;
type?: "web" | "native" | "user-agent-based";
//---- Not Part of RFC7591 Spec ----//
disabled?: boolean;
skip_consent?: boolean;
+1 -3
View File
@@ -206,9 +206,7 @@ export const clientRegistrationRequestSchema = z.object({
jwks_uri: z.string().optional(),
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: constrains redirect URI schemes and hosts when
// provided, and must agree with `type` when both are sent.
// OIDC Registration §2: constrains redirect URI schemes and hosts.
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.
+1 -2
View File
@@ -956,8 +956,7 @@ export function isPKCERequired(
// Determine if client is public
const isPublicClient =
client.tokenEndpointAuthMethod === "none" ||
client.type === "native" ||
client.type === "user-agent-based" ||
client.applicationType === "native" ||
client.public === true;
// PKCE always required for public clients