Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
},
"dependencies": {
"hono": "4.11.1",
"jose": "^6.2.3",
"mppx": "^0.4.11"
},
"devDependencies": {
Expand Down
88 changes: 73 additions & 15 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppContext>, 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
Expand All @@ -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);
};
}
Expand All @@ -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
Expand All @@ -80,6 +136,8 @@ export interface ProtectedRouteConfig {
*/
export function createProtectedRoute(config: ProtectedRouteConfig) {
return async (c: Context<AppContext>, next: Next) => {
const accessScope = createAccessScope(config, c.env, c.req.url);

const mppx = Mppx.create({
methods: [
tempo({
Expand All @@ -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);
};
}

Expand Down
75 changes: 55 additions & 20 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -102,11 +107,46 @@ async function proxyToOrigin(request: Request, env: Env): Promise<Response> {
*/
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<AppContext>,
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
Expand All @@ -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
);
}

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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") {
Expand All @@ -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;
Expand All @@ -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
Expand Down
Loading
Loading