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).
This commit is contained in:
Mel Ludowise 2026-06-02 05:30:49 -07:00 • committed by GitHub
commit a990780293
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -273,18 +273,28 @@ async function signAuthCode(
return new SignJWT({ ...payload, typ: "code" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setJti(crypto.randomUUID())
.setIssuer(issuer)
.setExpirationTime("5m")
.sign(JWT_SECRET);
}
// Authorization codes are single-use (RFC 6749 §4.1.2): remember redeemed code
// IDs until the code's own 5-minute expiry makes replay impossible anyway.
const redeemedCodeJtis = new Map<string, number>(); // jti → unix GC time
async function verifyAuthCode(
code: string,
issuer: string,
): Promise<CodePayload | undefined> {
try {
const { payload } = await jwtVerify(code, JWT_SECRET, { issuer });
if (payload.typ !== "code") return undefined;
if (payload.typ !== "code" || !payload.jti) return undefined;
const now = Math.floor(Date.now() / 1000);
for (const [k, gc] of redeemedCodeJtis)
if (gc < now) redeemedCodeJtis.delete(k);
if (redeemedCodeJtis.has(payload.jti)) return undefined; // already redeemed
redeemedCodeJtis.set(payload.jti, now + 5 * 60 + 10);
return payload as unknown as CodePayload;
} catch {
return undefined;
@ -366,6 +376,16 @@ async function handleAuthorize(req: Request, res: Response) {
});
return;
}
// PKCE is mandatory (the MCP auth spec requires it of clients, and this AS
// only advertises S256). Rejecting up front keeps stolen-code attacks out of
// the demo even though it has no real data to protect.
if (!code_challenge || code_challenge_method !== "S256") {
res.status(400).json({
error: "invalid_request",
error_description: "PKCE with S256 code_challenge is required",
});
return;
}
const issuer = resolveIssuer(req);
if (approved !== "1") {
@ -475,25 +495,34 @@ async function handleToken(req: Request, res: Response) {
});
return;
}
if (stored.code_challenge) {
if (!code_verifier) {
res.status(400).json({
error: "invalid_grant",
error_description: "Missing code_verifier",
});
return;
}
const hash = crypto
.createHash("sha256")
.update(code_verifier)
.digest("base64url");
if (hash !== stored.code_challenge) {
res.status(400).json({
error: "invalid_grant",
error_description: "PKCE verification failed",
});
return;
}
// PKCE verification (challenges are always present — /authorize requires them).
if (!code_verifier) {
res.status(400).json({
error: "invalid_grant",
error_description: "Missing code_verifier",
});
return;
}
const hash = crypto
.createHash("sha256")
.update(code_verifier)
.digest("base64url");
if (hash !== stored.code_challenge) {
res.status(400).json({
error: "invalid_grant",
error_description: "PKCE verification failed",
});
return;
}
// RFC 6749 §4.1.3 redirect_uri binding: if the client includes redirect_uri
// in the token request it must match the one from the authorization request.
// (OAuth 2.1 clients may omit it and rely on PKCE, which is enforced above.)
if (req.body.redirect_uri && req.body.redirect_uri !== stored.redirect_uri) {
res.status(400).json({
error: "invalid_grant",
error_description: "redirect_uri does not match authorization request",
});
return;
}
const scope = stored.scope ?? "read:secret";
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 {
*/
export function createApp(): Express {
const app = express();
// Wildcard CORS is deliberate: browser-based MCP hosts connect to this demo
// from arbitrary origins and must read WWW-Authenticate to drive the lazy
// auth flow. There are no cookies or ambient credentials to protect — all
// auth is an explicit Bearer header, and /token is a credential-less PKCE
// exchange — so cross-origin reads expose nothing a direct request wouldn't.
app.use(cors({ exposedHeaders: ["WWW-Authenticate"] }));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));