From 3f725715643133cf9db6c70845ba3016d33176c6 Mon Sep 17 00:00:00 2001 From: Jilles Soeters Date: Thu, 25 Jun 2026 09:57:13 -0500 Subject: [PATCH 1/2] Fix JWT payment scope bypass --- README.md | 4 +-- src/auth.ts | 88 +++++++++++++++++++++++++++++++++++++++++++--------- src/index.ts | 75 ++++++++++++++++++++++++++++++++------------ src/jwt.ts | 48 ++++++++++++++++++++++++++-- 4 files changed, 175 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index ed2443d..558a8b5 100644 --- a/README.md +++ b/README.md @@ -138,9 +138,9 @@ The built-in paid test route at `/__mpp/protected` always exists, even if you ch 3. Client retries with `Authorization: Payment`. 4. Proxy verifies the credential with `mppx`. 5. Proxy forwards to the origin and adds `Payment-Receipt`. -6. Proxy also issues an `auth_token` cookie valid for 1 hour. +6. Proxy also issues an `auth_token` cookie valid for 1 hour and scoped to the paid route/payment configuration. -That means MPP-native clients get standards-compliant receipts, while browsers and agents can reuse the cookie for repeated access during the valid period. +That means MPP-native clients get standards-compliant receipts, while browsers and agents can reuse the cookie for repeated access to the same paid scope during the valid period. ## Proxy Modes diff --git a/src/auth.ts b/src/auth.ts index 9696a25..bbc9b99 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -3,27 +3,55 @@ */ import { Context, Next, MiddlewareHandler } from "hono"; -import { getCookie } from "hono/cookie"; import { payment } from "mppx/hono"; import { Mppx, tempo } from "mppx/server"; import { createClient, http } from "viem"; import { tempo as tempoMainnet, tempoModerato } from "viem/chains"; -import { verifyJWT } from "./jwt"; +import { verifyJWT, type JWTAccessScope } from "./jwt"; import type { AppContext, Env } from "./env"; +const AUTH_COOKIE_NAME = "auth_token"; + +function getAuthCookieValues(cookieHeader: string | null): string[] { + if (!cookieHeader) { + return []; + } + + const values: string[] = []; + + for (const cookie of cookieHeader.split(";")) { + const [name, ...valueParts] = cookie.trim().split("="); + if (name !== AUTH_COOKIE_NAME || valueParts.length === 0) { + continue; + } + + const value = valueParts.join("="); + try { + values.push(decodeURIComponent(value)); + } catch { + values.push(value); + } + } + + return values; +} + /** * Creates a combined middleware that checks for valid cookie authentication * and conditionally applies payment middleware only if cookie auth fails * - * @param paymentMiddleware - The payment middleware to apply when no valid cookie exists + * @param paymentMiddleware - The payment middleware to apply when no valid scoped cookie exists + * @param expectedScope - Route/payment scope the cookie must authorize * @returns Combined authentication and payment middleware */ -export function requirePaymentOrCookie(paymentMw: MiddlewareHandler) { +export function requirePaymentOrCookie( + paymentMw: MiddlewareHandler, + expectedScope: JWTAccessScope, +) { return async (c: Context, next: Next) => { - // Check for valid cookie - const token = getCookie(c, "auth_token"); + const tokens = getAuthCookieValues(c.req.raw.headers.get("Cookie")); - if (token) { + if (tokens.length > 0) { const jwtSecret = c.env.JWT_SECRET; // Ensure JWT_SECRET is configured @@ -37,17 +65,20 @@ export function requirePaymentOrCookie(paymentMw: MiddlewareHandler) { ); } - const payload = await verifyJWT(token, jwtSecret); + for (const token of tokens) { + const payload = await verifyJWT(token, jwtSecret, expectedScope); - // If token is valid, skip payment and go directly to handler - if (payload) { - c.set("auth", payload); - await next(); // Call the handler - return; + // If token is valid for this exact route/payment scope, skip payment + // and go directly to the handler. + if (payload) { + c.set("auth", payload); + await next(); // Call the handler + return; + } } } - // No valid cookie - apply payment middleware + // No valid scoped cookie - apply payment middleware return await paymentMw(c, next); }; } @@ -70,6 +101,31 @@ export interface ProtectedRouteConfig { except_detection_ids?: number[]; } +export function createAccessScope( + config: ProtectedRouteConfig, + env: Env, + requestUrl: string, +): JWTAccessScope { + return { + version: 1, + paymentMethod: "tempo", + realm: new URL(requestUrl).host, + pattern: config.pattern, + amount: config.amount, + paymentCurrency: env.PAYMENT_CURRENCY.toLowerCase(), + payTo: env.PAY_TO.toLowerCase(), + tempoTestnet: env.TEMPO_TESTNET, + }; +} + +export function getAuthCookiePath(config: ProtectedRouteConfig): string { + if (!config.pattern.endsWith("/*")) { + return config.pattern; + } + + return config.pattern.slice(0, -2) || "/"; +} + /** * Creates middleware for a protected route that requires payment OR valid cookie * This dynamically creates payment middleware at request time to access environment variables @@ -80,6 +136,8 @@ export interface ProtectedRouteConfig { */ export function createProtectedRoute(config: ProtectedRouteConfig) { return async (c: Context, next: Next) => { + const accessScope = createAccessScope(config, c.env, c.req.url); + const mppx = Mppx.create({ methods: [ tempo({ @@ -103,7 +161,7 @@ export function createProtectedRoute(config: ProtectedRouteConfig) { }); // Apply the combined auth/payment middleware - return await requirePaymentOrCookie(paymentMw)(c, next); + return await requirePaymentOrCookie(paymentMw, accessScope)(c, next); }; } diff --git a/src/index.ts b/src/index.ts index 1ee36c8..0e658e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,11 @@ -import { Hono } from "hono"; +import { Hono, type Context } from "hono"; import { setCookie } from "hono/cookie"; -import { createProtectedRoute, type ProtectedRouteConfig } from "./auth"; +import { + createAccessScope, + createProtectedRoute, + getAuthCookiePath, + type ProtectedRouteConfig, +} from "./auth"; import { generateJWT } from "./jwt"; import { hasBotManagementException } from "./bot-management"; import type { AppContext, Env } from "./env"; @@ -102,11 +107,46 @@ async function proxyToOrigin(request: Request, env: Env): Promise { */ function pathMatchesPattern(path: string, pattern: string): boolean { if (pattern.endsWith("/*")) { - return path.startsWith(pattern.slice(0, -2)); + const prefix = pattern.slice(0, -2); + return path === prefix || path.startsWith(`${prefix}/`); } return path === pattern; } +function patternSpecificity(pattern: string): number { + if (pattern.endsWith("/*")) { + return pattern.slice(0, -2).length * 2; + } + + return pattern.length * 2 + 1; +} + +function setScopedAuthCookie( + c: Context, + token: string, + path: string, +) { + setCookie(c, "auth_token", token, { + httpOnly: true, + secure: true, + sameSite: "Strict", + maxAge: 3600, + path, + }); + + // Expire the old deployment-wide cookie so legacy unscoped sessions do not + // shadow newer route-scoped cookies with the same name. + if (path !== "/") { + setCookie(c, "auth_token", "", { + httpOnly: true, + secure: true, + sameSite: "Strict", + maxAge: 0, + path: "/", + }); + } +} + /** * Helper to find the protected route config for a given path * Includes both built-in protected routes and configured patterns @@ -115,10 +155,15 @@ function findProtectedRouteConfig( path: string, patterns: ProtectedRouteConfig[], ): ProtectedRouteConfig | null { - // Check built-in protected routes first, then configured patterns + // Prefer the most specific matching route so an overlapping broad, cheap + // wildcard cannot shadow a more expensive exact/narrow route. const allRoutes = [...BUILTIN_PROTECTED_PATHS, ...patterns]; return ( - allRoutes.find((config) => pathMatchesPattern(path, config.pattern)) ?? null + allRoutes + .filter((config) => pathMatchesPattern(path, config.pattern)) + .sort( + (a, b) => patternSpecificity(b.pattern) - patternSpecificity(a.pattern), + )[0] ?? null ); } @@ -161,6 +206,8 @@ app.use("*", async (c, next) => { // Use the protected route middleware const protectedMiddleware = createProtectedRoute(protectedConfig); + const accessScope = createAccessScope(protectedConfig, c.env, c.req.url); + const authCookiePath = getAuthCookiePath(protectedConfig); let jwtToken = ""; const result = await protectedMiddleware(c, async () => { @@ -171,7 +218,7 @@ app.use("*", async (c, next) => { // This is a new payment - generate JWT cookie // Note: This runs after payment verification but BEFORE settlement. // We'll check if settlement succeeded before actually using the token. - jwtToken = await generateJWT(c.env.JWT_SECRET, 3600); + jwtToken = await generateJWT(c.env.JWT_SECRET, accessScope, 3600); } if (path === "/__mpp/protected") { @@ -197,13 +244,7 @@ app.use("*", async (c, next) => { // Built-in protected endpoint response is created inside the payment // callback so the receipt header gets attached to the JSON body. if (jwtToken) { - setCookie(c, "auth_token", jwtToken, { - httpOnly: true, - secure: true, - sameSite: "Strict", - maxAge: 3600, - path: "/", - }); + setScopedAuthCookie(c, jwtToken, authCookiePath); } return c.res; @@ -217,13 +258,7 @@ app.use("*", async (c, next) => { if (jwtToken || paymentReceipt) { // Use Hono's setCookie to generate the proper Set-Cookie header if (jwtToken) { - setCookie(c, "auth_token", jwtToken, { - httpOnly: true, - secure: true, - sameSite: "Strict", - maxAge: 3600, - path: "/", - }); + setScopedAuthCookie(c, jwtToken, authCookiePath); } // Clone the origin response and add our cookie header diff --git a/src/jwt.ts b/src/jwt.ts index 0a620f7..863ddea 100644 --- a/src/jwt.ts +++ b/src/jwt.ts @@ -3,10 +3,30 @@ * Implements stateless authentication with HMAC-SHA256 signatures */ +export interface JWTAccessScope { + /** Schema version for future-compatible validation */ + version: 1; + /** Payment method used for the charge */ + paymentMethod: "tempo"; + /** Hostname/realm the payment challenge was issued for */ + realm: string; + /** Protected route pattern that was paid for */ + pattern: string; + /** Amount paid for the protected route */ + amount: string; + /** Token address used for payment */ + paymentCurrency: string; + /** Recipient wallet for the payment */ + payTo: string; + /** Tempo network selector */ + tempoTestnet: boolean; +} + export interface JWTPayload { paid: boolean; // indicates payment was verified iat: number; // issued at (seconds since epoch) exp: number; // expires at (seconds since epoch) + scope: JWTAccessScope; // route/payment scope this token authorizes } /** @@ -68,14 +88,29 @@ async function importSecretKey(secret: string): Promise { ); } +function scopesMatch(a: JWTAccessScope, b: JWTAccessScope): boolean { + return ( + a.version === b.version && + a.paymentMethod === b.paymentMethod && + a.realm === b.realm && + a.pattern === b.pattern && + a.amount === b.amount && + a.paymentCurrency === b.paymentCurrency && + a.payTo === b.payTo && + a.tempoTestnet === b.tempoTestnet + ); +} + /** * Generate a JWT token * @param secret - The secret key for signing + * @param scope - Route/payment scope this token authorizes * @param expiresInSeconds - Token validity duration (default: 3600 = 1 hour) * @returns JWT token string */ export async function generateJWT( secret: string, + scope: JWTAccessScope, expiresInSeconds: number = 3600, ): Promise { const now = Math.floor(Date.now() / 1000); @@ -91,6 +126,7 @@ export async function generateJWT( paid: true, iat: now, exp: now + expiresInSeconds, + scope, }; // Encode header and payload @@ -119,11 +155,13 @@ export async function generateJWT( * Verify and decode a JWT token * @param token - The JWT token to verify * @param secret - The secret key for verification + * @param expectedScope - Optional route/payment scope the token must match * @returns Decoded payload if valid, null otherwise */ export async function verifyJWT( token: string, secret: string, + expectedScope?: JWTAccessScope, ): Promise { try { // Split token into parts @@ -155,10 +193,14 @@ export async function verifyJWT( const payloadStr = arrayBufferToString(payloadBuffer); const payload = JSON.parse(payloadStr) as JWTPayload; - // Check expiration + // Check required claims and expiration const now = Math.floor(Date.now() / 1000); - if (payload.exp < now) { - return null; // Token expired + if (payload.paid !== true || payload.exp < now || !payload.scope) { + return null; + } + + if (expectedScope && !scopesMatch(payload.scope, expectedScope)) { + return null; } return payload; From eec15d4ee7c18fc204567b37c3504cda5f31caf8 Mon Sep 17 00:00:00 2001 From: Jilles Soeters Date: Thu, 25 Jun 2026 10:09:26 -0500 Subject: [PATCH 2/2] refactor jwt handling to use jose --- package-lock.json | 7 +- package.json | 1 + src/jwt.ts | 160 ++++++++++++---------------------------------- 3 files changed, 47 insertions(+), 121 deletions(-) diff --git a/package-lock.json b/package-lock.json index 725f663..8ec2673 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "hono": "4.11.1", + "jose": "^6.2.3", "mppx": "^0.4.11" }, "devDependencies": { @@ -3325,9 +3326,9 @@ } }, "node_modules/jose": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz", - "integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" diff --git a/package.json b/package.json index 17c9ec3..8ff56ac 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ }, "dependencies": { "hono": "4.11.1", + "jose": "^6.2.3", "mppx": "^0.4.11" }, "devDependencies": { diff --git a/src/jwt.ts b/src/jwt.ts index 863ddea..40add35 100644 --- a/src/jwt.ts +++ b/src/jwt.ts @@ -1,6 +1,8 @@ +import { SignJWT, jwtVerify, type JWTPayload as JoseJWTPayload } from "jose"; + /** - * JWT utility functions using Web Crypto API (crypto.subtle) - * Implements stateless authentication with HMAC-SHA256 signatures + * JWT utility functions using jose. + * Implements stateless authentication with HMAC-SHA256 signatures. */ export interface JWTAccessScope { @@ -22,69 +24,45 @@ export interface JWTAccessScope { tempoTestnet: boolean; } -export interface JWTPayload { +export interface JWTPayload extends JoseJWTPayload { paid: boolean; // indicates payment was verified iat: number; // issued at (seconds since epoch) exp: number; // expires at (seconds since epoch) scope: JWTAccessScope; // route/payment scope this token authorizes } -/** - * Base64URL encoding (URL-safe base64 without padding) - */ -function base64UrlEncode(data: BufferSource): string { - // Convert to Uint8Array if it's an ArrayBuffer - const bytes = ArrayBuffer.isView(data) - ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) - : new Uint8Array(data); - const base64 = btoa(String.fromCharCode(...bytes)); - return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); -} +const JWT_ALGORITHM = "HS256"; +const JWT_TYPE = "JWT"; +const encoder = new TextEncoder(); -/** - * Base64URL decoding - */ -function base64UrlDecode(str: string): Uint8Array { - // Add padding back - const paddedStr = str + "==".substring(0, (4 - (str.length % 4)) % 4); - // Replace URL-safe characters - const base64 = paddedStr.replace(/-/g, "+").replace(/_/g, "/"); - // Decode - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; +function getSecretKey(secret: string): Uint8Array { + return encoder.encode(secret); } -/** - * Convert string to Uint8Array - */ -function stringToArrayBuffer(str: string): Uint8Array { - const encoder = new TextEncoder(); - return encoder.encode(str); +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } -/** - * Convert BufferSource to string - */ -function arrayBufferToString(buffer: BufferSource): string { - const decoder = new TextDecoder(); - return decoder.decode(buffer); +function isJWTAccessScope(scope: unknown): scope is JWTAccessScope { + return ( + isRecord(scope) && + scope.version === 1 && + scope.paymentMethod === "tempo" && + typeof scope.realm === "string" && + typeof scope.pattern === "string" && + typeof scope.amount === "string" && + typeof scope.paymentCurrency === "string" && + typeof scope.payTo === "string" && + typeof scope.tempoTestnet === "boolean" + ); } -/** - * Import the JWT secret as a CryptoKey for HMAC operations - */ -async function importSecretKey(secret: string): Promise { - const keyData = stringToArrayBuffer(secret); - return await crypto.subtle.importKey( - "raw", - keyData, - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign", "verify"], +function isJWTPayload(payload: JoseJWTPayload): payload is JWTPayload { + return ( + payload.paid === true && + typeof payload.iat === "number" && + typeof payload.exp === "number" && + isJWTAccessScope(payload.scope) ); } @@ -115,40 +93,11 @@ export async function generateJWT( ): Promise { const now = Math.floor(Date.now() / 1000); - // JWT Header - const header = { - alg: "HS256", - typ: "JWT", - }; - - // JWT Payload - const payload: JWTPayload = { - paid: true, - iat: now, - exp: now + expiresInSeconds, - scope, - }; - - // Encode header and payload - const encodedHeader = base64UrlEncode( - stringToArrayBuffer(JSON.stringify(header)), - ); - const encodedPayload = base64UrlEncode( - stringToArrayBuffer(JSON.stringify(payload)), - ); - - // Create signature - const dataToSign = `${encodedHeader}.${encodedPayload}`; - const key = await importSecretKey(secret); - const signatureBuffer = await crypto.subtle.sign( - "HMAC", - key, - stringToArrayBuffer(dataToSign), - ); - const encodedSignature = base64UrlEncode(signatureBuffer); - - // Return complete JWT - return `${dataToSign}.${encodedSignature}`; + return await new SignJWT({ paid: true, scope }) + .setProtectedHeader({ alg: JWT_ALGORITHM, typ: JWT_TYPE }) + .setIssuedAt(now) + .setExpirationTime(now + expiresInSeconds) + .sign(getSecretKey(secret)); } /** @@ -164,38 +113,13 @@ export async function verifyJWT( expectedScope?: JWTAccessScope, ): Promise { try { - // Split token into parts - const parts = token.split("."); - if (parts.length !== 3) { - return null; - } - - const [encodedHeader, encodedPayload, encodedSignature] = parts; - - // Verify signature - const dataToVerify = `${encodedHeader}.${encodedPayload}`; - const key = await importSecretKey(secret); - const signatureBuffer = base64UrlDecode(encodedSignature); - - const isValid = await crypto.subtle.verify( - "HMAC", - key, - signatureBuffer, - stringToArrayBuffer(dataToVerify), - ); - - if (!isValid) { - return null; - } - - // Decode and parse payload - const payloadBuffer = base64UrlDecode(encodedPayload); - const payloadStr = arrayBufferToString(payloadBuffer); - const payload = JSON.parse(payloadStr) as JWTPayload; + const { payload } = await jwtVerify(token, getSecretKey(secret), { + algorithms: [JWT_ALGORITHM], + typ: JWT_TYPE, + requiredClaims: ["exp", "iat"], + }); - // Check required claims and expiration - const now = Math.floor(Date.now() / 1000); - if (payload.paid !== true || payload.exp < now || !payload.scope) { + if (!isJWTPayload(payload)) { return null; } @@ -205,7 +129,7 @@ export async function verifyJWT( return payload; } catch { - // Invalid token format or parsing error + // Invalid token format, signature, claims, expiration, or parsing error. return null; } }