Skip to content

Commit 699b09a

Browse files
fix(oidc-provider, mcp): drop "none" alg, default plain PKCE off, reject missing PKCE method (#9575)
1 parent b4bc65a commit 699b09a

7 files changed

Lines changed: 364 additions & 55 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"better-auth": patch
3+
---
4+
5+
fix(oidc-provider, mcp): drop `"none"` from advertised signing algorithms, default `allowPlainCodeChallengeMethod` to `false`, and reject missing PKCE method
6+
7+
The legacy `oidc-provider` and `mcp` plugins now follow OAuth 2.1 (RFC 9700) on three protocol gates:
8+
9+
- `id_token_signing_alg_values_supported` (oidc-provider, mcp) and `resource_signing_alg_values_supported` (mcp) no longer include `"none"`. Relying parties that negotiate from this list will no longer be steered toward unsigned tokens.
10+
- `allowPlainCodeChallengeMethod` defaults to `false`. Callers who need `plain` PKCE must opt in explicitly.
11+
- Under the secure default the authorize endpoint no longer silently rewrites a missing `code_challenge_method` to `"plain"` before the allowlist check. A request that provides `code_challenge` without `code_challenge_method` is now rejected with `invalid_request`; the inverse case (`code_challenge_method` without `code_challenge`) is also rejected so no inconsistent PKCE state is persisted on the authorization code record.
12+
13+
Non-breaking for callers who never relied on `"none"` advertisement or the plain default. Callers who explicitly set `allowPlainCodeChallengeMethod: true` keep `plain` on the allowlist **and** retain the legacy "missing method defaults to plain" behavior for backward compatibility, so existing integrations that opted into plain PKCE continue to work. The next-minor on `next` will drop both the `plain` allowlist entry and this fallback; until then, the option is the single explicit knob for legacy behavior. Migrate to `@better-auth/oauth-provider` for the canonical, spec-aligned implementation.

packages/better-auth/src/plugins/mcp/authorize.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -149,25 +149,49 @@ export async function authorizeMCPOAuth(
149149
);
150150
}
151151

152-
if (!query.code_challenge_method) {
153-
query.code_challenge_method = "plain";
154-
}
155-
156-
if (
157-
![
158-
"s256",
159-
options.allowPlainCodeChallengeMethod ? "plain" : "s256",
160-
].includes(query.code_challenge_method?.toLowerCase() || "")
161-
) {
152+
if (query.code_challenge_method && !query.code_challenge) {
162153
throw ctx.redirect(
163154
redirectErrorURL(
164155
query.redirect_uri,
165156
"invalid_request",
166-
"invalid code_challenge method",
157+
"code_challenge_method requires code_challenge",
167158
),
168159
);
169160
}
170161

162+
if (query.code_challenge) {
163+
const allowedCodeChallengeMethods = options.allowPlainCodeChallengeMethod
164+
? ["s256", "plain"]
165+
: ["s256"];
166+
let codeChallengeMethod: AuthorizationQuery["code_challenge_method"] =
167+
query.code_challenge_method?.toLowerCase() as AuthorizationQuery["code_challenge_method"];
168+
// Backward-compat: callers who explicitly opt into plain PKCE retain the
169+
// legacy "default missing method to `plain`" behavior. The secure default
170+
// (`allowPlainCodeChallengeMethod: false`) still rejects a missing method.
171+
// FIXME(legacy-plain-pkce-removal): remove this fallback on next; require
172+
// callers to send `code_challenge_method` explicitly.
173+
if (!codeChallengeMethod && options.allowPlainCodeChallengeMethod) {
174+
codeChallengeMethod = "plain";
175+
}
176+
if (
177+
!codeChallengeMethod ||
178+
!allowedCodeChallengeMethods.includes(codeChallengeMethod)
179+
) {
180+
throw ctx.redirect(
181+
redirectErrorURL(
182+
query.redirect_uri,
183+
"invalid_request",
184+
"invalid code_challenge method",
185+
),
186+
);
187+
}
188+
// Persist the normalized value back so the verification record stores the
189+
// lowercased and (optionally) fallback-resolved method. The token endpoint
190+
// compares against `"plain"` exactly, so casing variations or a missing
191+
// method on the opt-in path would otherwise break PKCE verification.
192+
query.code_challenge_method = codeChallengeMethod;
193+
}
194+
171195
const code = generateRandomString(32, "a-z", "A-Z", "0-9");
172196
const codeExpiresInMs = opts.codeExpiresIn * 1000;
173197
const expiresAt = new Date(Date.now() + codeExpiresInMs);

packages/better-auth/src/plugins/mcp/index.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export const getMCPProviderMetadata = (
8383
"urn:mace:incommon:iap:bronze",
8484
],
8585
subject_types_supported: ["public"],
86-
id_token_signing_alg_values_supported: ["RS256", "none"],
86+
id_token_signing_alg_values_supported: ["RS256"],
8787
token_endpoint_auth_methods_supported: [
8888
"client_secret_basic",
8989
"client_secret_post",
@@ -124,7 +124,7 @@ export const getMCPProtectedResourceMetadata = (
124124
"offline_access",
125125
],
126126
bearer_methods_supported: ["header"],
127-
resource_signing_alg_values_supported: ["RS256", "none"],
127+
resource_signing_alg_values_supported: ["RS256"],
128128
};
129129
};
130130

@@ -175,7 +175,7 @@ export const mcp = (options: MCPOptions) => {
175175
defaultScope: "openid",
176176
accessTokenExpiresIn: 3600,
177177
refreshTokenExpiresIn: 604800,
178-
allowPlainCodeChallengeMethod: true,
178+
allowPlainCodeChallengeMethod: false,
179179
...options.oidcConfig,
180180
loginPage: options.loginPage,
181181
scopes: [
@@ -664,18 +664,20 @@ export const mcp = (options: MCPOptions) => {
664664
});
665665
}
666666

667-
const challenge =
668-
value.codeChallengeMethod === "plain"
669-
? code_verifier
670-
: await createHash("SHA-256", "base64urlnopad").digest(
671-
code_verifier,
672-
);
667+
if (value.codeChallenge) {
668+
const challenge =
669+
value.codeChallengeMethod === "plain"
670+
? code_verifier
671+
: await createHash("SHA-256", "base64urlnopad").digest(
672+
code_verifier,
673+
);
673674

674-
if (challenge !== value.codeChallenge) {
675-
throw new APIError("UNAUTHORIZED", {
676-
error_description: "code verification failed",
677-
error: "invalid_request",
678-
});
675+
if (challenge !== value.codeChallenge) {
676+
throw new APIError("UNAUTHORIZED", {
677+
error_description: "code verification failed",
678+
error: "invalid_request",
679+
});
680+
}
679681
}
680682

681683
const requestedScopes = value.scope;

packages/better-auth/src/plugins/mcp/mcp.test.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ describe("mcp", async () => {
441441
response_modes_supported: ["query"],
442442
grant_types_supported: ["authorization_code", "refresh_token"],
443443
subject_types_supported: ["public"],
444-
id_token_signing_alg_values_supported: ["RS256", "none"],
444+
id_token_signing_alg_values_supported: ["RS256"],
445445
token_endpoint_auth_methods_supported: [
446446
"client_secret_basic",
447447
"client_secret_post",
@@ -475,7 +475,7 @@ describe("mcp", async () => {
475475
jwks_uri: `${baseURL}/api/auth/mcp/jwks`,
476476
scopes_supported: ["openid", "profile", "email", "offline_access"],
477477
bearer_methods_supported: ["header"],
478-
resource_signing_alg_values_supported: ["RS256", "none"],
478+
resource_signing_alg_values_supported: ["RS256"],
479479
});
480480
});
481481

@@ -1177,3 +1177,39 @@ describe("mcp refresh_token grant client authentication", () => {
11771177
expect(body?.access_token).toBeUndefined();
11781178
});
11791179
});
1180+
1181+
/**
1182+
* @see https://github.com/better-auth/better-auth/security/advisories/GHSA-9h47-pqcx-hjr4
1183+
*/
1184+
describe("mcp discovery metadata (security)", async () => {
1185+
const { auth } = await getTestInstance({
1186+
baseURL: "http://localhost:3000",
1187+
plugins: [mcp({ loginPage: "/login" })],
1188+
});
1189+
1190+
it("/.well-known/oauth-authorization-server must not advertise alg=none", async () => {
1191+
const res = await auth.handler(
1192+
new Request(
1193+
"http://localhost:3000/api/auth/.well-known/oauth-authorization-server",
1194+
{ method: "GET" },
1195+
),
1196+
);
1197+
const body = (await res.json()) as {
1198+
id_token_signing_alg_values_supported: string[];
1199+
};
1200+
expect(body.id_token_signing_alg_values_supported).not.toContain("none");
1201+
});
1202+
1203+
it("/.well-known/oauth-protected-resource must not advertise alg=none", async () => {
1204+
const res = await auth.handler(
1205+
new Request(
1206+
"http://localhost:3000/api/auth/.well-known/oauth-protected-resource",
1207+
{ method: "GET" },
1208+
),
1209+
);
1210+
const body = (await res.json()) as {
1211+
resource_signing_alg_values_supported: string[];
1212+
};
1213+
expect(body.resource_signing_alg_values_supported).not.toContain("none");
1214+
});
1215+
});

packages/better-auth/src/plugins/oidc-provider/authorize.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -197,25 +197,51 @@ export async function authorize(
197197
);
198198
}
199199

200-
if (!query.code_challenge_method) {
201-
query.code_challenge_method = "plain";
202-
}
203-
204-
if (
205-
![
206-
"s256",
207-
options.allowPlainCodeChallengeMethod ? "plain" : "s256",
208-
].includes(query.code_challenge_method?.toLowerCase() || "")
209-
) {
200+
if (query.code_challenge_method && !query.code_challenge) {
210201
return handleRedirect(
211202
formatErrorURL(
212203
query.redirect_uri,
213204
"invalid_request",
214-
"invalid code_challenge method",
205+
"code_challenge_method requires code_challenge",
215206
),
216207
);
217208
}
218209

210+
if (query.code_challenge) {
211+
const allowedCodeChallengeMethods = options.allowPlainCodeChallengeMethod
212+
? ["s256", "plain"]
213+
: ["s256"];
214+
let codeChallengeMethod: AuthorizationQuery["code_challenge_method"] =
215+
query.code_challenge_method?.toLowerCase() as AuthorizationQuery["code_challenge_method"];
216+
// Backward-compat: callers who explicitly opt into plain PKCE retain the
217+
// legacy "default missing method to `plain`" behavior. The secure default
218+
// (`allowPlainCodeChallengeMethod: false`) still rejects a missing method
219+
// as `invalid_request`. The whole branch should be removed once the next
220+
// minor drops the `plain` PKCE allowlist entry (see FIXME below).
221+
// FIXME(legacy-plain-pkce-removal): remove this fallback on next; require
222+
// callers to send `code_challenge_method` explicitly.
223+
if (!codeChallengeMethod && options.allowPlainCodeChallengeMethod) {
224+
codeChallengeMethod = "plain";
225+
}
226+
if (
227+
!codeChallengeMethod ||
228+
!allowedCodeChallengeMethods.includes(codeChallengeMethod)
229+
) {
230+
return handleRedirect(
231+
formatErrorURL(
232+
query.redirect_uri,
233+
"invalid_request",
234+
"invalid code_challenge method",
235+
),
236+
);
237+
}
238+
// Persist the normalized value back so the verification record stores the
239+
// lowercased and (optionally) fallback-resolved method. The token endpoint
240+
// compares against `"plain"` exactly, so casing variations or a missing
241+
// method on the opt-in path would otherwise break PKCE verification.
242+
query.code_challenge_method = codeChallengeMethod;
243+
}
244+
219245
const code = generateRandomString(32, "a-z", "A-Z", "0-9");
220246
const codeExpiresInMs = opts.codeExpiresIn! * 1000;
221247
const expiresAt = new Date(Date.now() + codeExpiresInMs);

packages/better-auth/src/plugins/oidc-provider/index.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,7 @@ export const getMetadata = (
9595
? jwtPlugin.options.jwt.issuer
9696
: (ctx.context.options.baseURL as string);
9797
const baseURL = ctx.context.baseURL;
98-
const supportedAlgs = options?.useJWTPlugin
99-
? ["RS256", "EdDSA", "none"]
100-
: ["HS256", "none"];
98+
const supportedAlgs = options?.useJWTPlugin ? ["RS256", "EdDSA"] : ["HS256"];
10199
return {
102100
issuer,
103101
authorization_endpoint: `${baseURL}/oauth2/authorize`,
@@ -309,7 +307,7 @@ export const oidcProvider = (options: OIDCOptions) => {
309307
defaultScope: "openid",
310308
accessTokenExpiresIn: DEFAULT_ACCESS_TOKEN_EXPIRES_IN,
311309
refreshTokenExpiresIn: DEFAULT_REFRESH_TOKEN_EXPIRES_IN,
312-
allowPlainCodeChallengeMethod: true,
310+
allowPlainCodeChallengeMethod: false,
313311
storeClientSecret: "plain" as const,
314312
...options,
315313
scopes: [
@@ -983,18 +981,20 @@ export const oidcProvider = (options: OIDCOptions) => {
983981
});
984982
}
985983
}
986-
const challenge =
987-
value.codeChallengeMethod === "plain"
988-
? code_verifier
989-
: await createHash("SHA-256", "base64urlnopad").digest(
990-
code_verifier,
991-
);
992-
993-
if (challenge !== value.codeChallenge) {
994-
throw new APIError("UNAUTHORIZED", {
995-
error_description: "code verification failed",
996-
error: "invalid_request",
997-
});
984+
if (value.codeChallenge) {
985+
const challenge =
986+
value.codeChallengeMethod === "plain"
987+
? code_verifier
988+
: await createHash("SHA-256", "base64urlnopad").digest(
989+
code_verifier,
990+
);
991+
992+
if (challenge !== value.codeChallenge) {
993+
throw new APIError("UNAUTHORIZED", {
994+
error_description: "code verification failed",
995+
error: "invalid_request",
996+
});
997+
}
998998
}
999999

10001000
const requestedScopes = value.scope;

0 commit comments

Comments
 (0)