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" })
|
return new SignJWT({ ...payload, typ: "code" })
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
|
.setJti(crypto.randomUUID())
|
||||||
.setIssuer(issuer)
|
.setIssuer(issuer)
|
||||||
.setExpirationTime("5m")
|
.setExpirationTime("5m")
|
||||||
.sign(JWT_SECRET);
|
.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(
|
async function verifyAuthCode(
|
||||||
code: string,
|
code: string,
|
||||||
issuer: string,
|
issuer: string,
|
||||||
): Promise<CodePayload | undefined> {
|
): Promise<CodePayload | undefined> {
|
||||||
try {
|
try {
|
||||||
const { payload } = await jwtVerify(code, JWT_SECRET, { issuer });
|
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;
|
return payload as unknown as CodePayload;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -366,6 +376,16 @@ async function handleAuthorize(req: Request, res: Response) {
|
||||||
});
|
});
|
||||||
return;
|
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);
|
const issuer = resolveIssuer(req);
|
||||||
|
|
||||||
if (approved !== "1") {
|
if (approved !== "1") {
|
||||||
|
|
@ -475,25 +495,34 @@ async function handleToken(req: Request, res: Response) {
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (stored.code_challenge) {
|
// PKCE verification (challenges are always present — /authorize requires them).
|
||||||
if (!code_verifier) {
|
if (!code_verifier) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
error: "invalid_grant",
|
error: "invalid_grant",
|
||||||
error_description: "Missing code_verifier",
|
error_description: "Missing code_verifier",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hash = crypto
|
const hash = crypto
|
||||||
.createHash("sha256")
|
.createHash("sha256")
|
||||||
.update(code_verifier)
|
.update(code_verifier)
|
||||||
.digest("base64url");
|
.digest("base64url");
|
||||||
if (hash !== stored.code_challenge) {
|
if (hash !== stored.code_challenge) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
error: "invalid_grant",
|
error: "invalid_grant",
|
||||||
error_description: "PKCE verification failed",
|
error_description: "PKCE verification failed",
|
||||||
});
|
});
|
||||||
return;
|
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 scope = stored.scope ?? "read:secret";
|
||||||
const sid = crypto.randomBytes(16).toString("hex"); // new session per authorization_code grant
|
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 {
|
export function createApp(): Express {
|
||||||
const app = 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(cors({ exposedHeaders: ["WWW-Authenticate"] }));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue