Skip to content

Commit a990780

Browse files
Require PKCE and bind redirect_uri in lazy-auth-server token exchange (#681)
* Require PKCE and bind redirect_uri in lazy-auth-server token exchange Hardens the example's mock authorization server: - /authorize now requires a PKCE S256 code_challenge (the MCP auth spec mandates PKCE for clients; previously a code issued without a challenge skipped verification at the token endpoint) - /token rejects authorization-code exchanges where a provided redirect_uri does not match the authorization request (RFC 6749 §4.1.3); OAuth 2.1 clients that omit it still work, relying on the now-mandatory PKCE binding * Enforce single-use authorization codes Track redeemed code IDs (jti) in memory until the code's own 5-minute expiry; replaying a code at the token endpoint now fails with invalid_grant (RFC 6749 §4.1.2). Also documents why wildcard CORS is intentional for this demo (browser-based hosts must read WWW-Authenticate; no ambient credentials exist to protect).
1 parent 7d4434e commit a990780

1 file changed

Lines changed: 54 additions & 20 deletions

File tree

examples/lazy-auth-server/server.ts

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -273,18 +273,28 @@ async function signAuthCode(
273273
return new SignJWT({ ...payload, typ: "code" })
274274
.setProtectedHeader({ alg: "HS256" })
275275
.setIssuedAt()
276+
.setJti(crypto.randomUUID())
276277
.setIssuer(issuer)
277278
.setExpirationTime("5m")
278279
.sign(JWT_SECRET);
279280
}
280281

282+
// Authorization codes are single-use (RFC 6749 §4.1.2): remember redeemed code
283+
// IDs until the code's own 5-minute expiry makes replay impossible anyway.
284+
const redeemedCodeJtis = new Map<string, number>(); // jti → unix GC time
285+
281286
async function verifyAuthCode(
282287
code: string,
283288
issuer: string,
284289
): Promise<CodePayload | undefined> {
285290
try {
286291
const { payload } = await jwtVerify(code, JWT_SECRET, { issuer });
287-
if (payload.typ !== "code") return undefined;
292+
if (payload.typ !== "code" || !payload.jti) return undefined;
293+
const now = Math.floor(Date.now() / 1000);
294+
for (const [k, gc] of redeemedCodeJtis)
295+
if (gc < now) redeemedCodeJtis.delete(k);
296+
if (redeemedCodeJtis.has(payload.jti)) return undefined; // already redeemed
297+
redeemedCodeJtis.set(payload.jti, now + 5 * 60 + 10);
288298
return payload as unknown as CodePayload;
289299
} catch {
290300
return undefined;
@@ -366,6 +376,16 @@ async function handleAuthorize(req: Request, res: Response) {
366376
});
367377
return;
368378
}
379+
// PKCE is mandatory (the MCP auth spec requires it of clients, and this AS
380+
// only advertises S256). Rejecting up front keeps stolen-code attacks out of
381+
// the demo even though it has no real data to protect.
382+
if (!code_challenge || code_challenge_method !== "S256") {
383+
res.status(400).json({
384+
error: "invalid_request",
385+
error_description: "PKCE with S256 code_challenge is required",
386+
});
387+
return;
388+
}
369389
const issuer = resolveIssuer(req);
370390

371391
if (approved !== "1") {
@@ -475,25 +495,34 @@ async function handleToken(req: Request, res: Response) {
475495
});
476496
return;
477497
}
478-
if (stored.code_challenge) {
479-
if (!code_verifier) {
480-
res.status(400).json({
481-
error: "invalid_grant",
482-
error_description: "Missing code_verifier",
483-
});
484-
return;
485-
}
486-
const hash = crypto
487-
.createHash("sha256")
488-
.update(code_verifier)
489-
.digest("base64url");
490-
if (hash !== stored.code_challenge) {
491-
res.status(400).json({
492-
error: "invalid_grant",
493-
error_description: "PKCE verification failed",
494-
});
495-
return;
496-
}
498+
// PKCE verification (challenges are always present — /authorize requires them).
499+
if (!code_verifier) {
500+
res.status(400).json({
501+
error: "invalid_grant",
502+
error_description: "Missing code_verifier",
503+
});
504+
return;
505+
}
506+
const hash = crypto
507+
.createHash("sha256")
508+
.update(code_verifier)
509+
.digest("base64url");
510+
if (hash !== stored.code_challenge) {
511+
res.status(400).json({
512+
error: "invalid_grant",
513+
error_description: "PKCE verification failed",
514+
});
515+
return;
516+
}
517+
// RFC 6749 §4.1.3 redirect_uri binding: if the client includes redirect_uri
518+
// in the token request it must match the one from the authorization request.
519+
// (OAuth 2.1 clients may omit it and rely on PKCE, which is enforced above.)
520+
if (req.body.redirect_uri && req.body.redirect_uri !== stored.redirect_uri) {
521+
res.status(400).json({
522+
error: "invalid_grant",
523+
error_description: "redirect_uri does not match authorization request",
524+
});
525+
return;
497526
}
498527
const scope = stored.scope ?? "read:secret";
499528
const sid = crypto.randomBytes(16).toString("hex"); // new session per authorization_code grant
@@ -789,6 +818,11 @@ export function createServer(authInfo?: AuthInfo, req?: Request): McpServer {
789818
*/
790819
export function createApp(): Express {
791820
const app = express();
821+
// Wildcard CORS is deliberate: browser-based MCP hosts connect to this demo
822+
// from arbitrary origins and must read WWW-Authenticate to drive the lazy
823+
// auth flow. There are no cookies or ambient credentials to protect — all
824+
// auth is an explicit Bearer header, and /token is a credential-less PKCE
825+
// exchange — so cross-origin reads expose nothing a direct request wouldn't.
792826
app.use(cors({ exposedHeaders: ["WWW-Authenticate"] }));
793827
app.use(express.json());
794828
app.use(express.urlencoded({ extended: true }));

0 commit comments

Comments
 (0)