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:
parent
7d4434e991
commit
a990780293
1 changed files with 54 additions and 20 deletions
|
|
@ -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 }));
|
||||
|
|
|
|||
Loading…
Reference in a new issue