diff --git a/examples/facilitator-server/src/app.ts b/examples/facilitator-server/src/app.ts index 2454d970..05d192be 100644 --- a/examples/facilitator-server/src/app.ts +++ b/examples/facilitator-server/src/app.ts @@ -100,13 +100,16 @@ interface Facilitator { }; } +export type AppModule = unknown; + export interface AppConfig { facilitator: Facilitator; tracking?: ResourceTrackingModule; + modules?: AppModule[]; } export function createApp(config: AppConfig) { - const { facilitator, tracking } = config; + const { facilitator, tracking, modules = [] } = config; const safeTrack = async ( fn: (module: ResourceTrackingModule) => Promise, label = "tracking" @@ -131,7 +134,7 @@ export function createApp(config: AppConfig) { } }; - const app = new Elysia({ adapter: node() }) + const baseApp = new Elysia({ adapter: node() }) .use( logger({ autoLogging: true, @@ -143,7 +146,13 @@ export function createApp(config: AppConfig) { serviceName: process.env.OTEL_SERVICE_NAME ?? "x402-facilitator", spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())], }) - ) + ); + + for (const module of modules) { + baseApp.use(module as any); + } + + const app = baseApp .get("/", () => file("./public/index.html")) .use(staticPlugin()) .post("/verify", async ({ body, request, status }) => { diff --git a/examples/facilitator-server/src/index.ts b/examples/facilitator-server/src/index.ts index a22a1a80..25960e89 100644 --- a/examples/facilitator-server/src/index.ts +++ b/examples/facilitator-server/src/index.ts @@ -11,6 +11,8 @@ * - CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET: For CDP signer * - EVM_PRIVATE_KEY, SVM_PRIVATE_KEY: For private key signer (fallback) * - EVM_RPC_URL_BASE, EVM_RPC_URL_BASE_SEPOLIA: RPC URLs + * - BEARER_TOKEN: Required bearer token for /verify and /settle + * - BEARER_TOKENS: Optional comma-separated bearer token list (overrides BEARER_TOKEN) */ import pg from "pg"; @@ -20,10 +22,26 @@ import { createFacilitator } from "@daydreamsai/facilitator"; import { createApp } from "./app.js"; import { createDrizzleAdapter, createTracking } from "./db.js"; import { runMigrations } from "./db-migrate.js"; +import { createBearerTokenModule } from "./modules/bearer-token.js"; import * as trackingSchema from "./schema/tracking.js"; const PORT = parseInt(process.env.PORT || "8090", 10); const DATABASE_URL = process.env.DATABASE_URL; +const BEARER_TOKEN = process.env.BEARER_TOKEN?.trim(); +const BEARER_TOKENS = process.env.BEARER_TOKENS?.split(",") + .map((token) => token.trim()) + .filter(Boolean); +const TOKENS = BEARER_TOKENS && BEARER_TOKENS.length > 0 + ? BEARER_TOKENS + : BEARER_TOKEN + ? [BEARER_TOKEN] + : []; + +if (TOKENS.length === 0) { + throw new Error( + "Set BEARER_TOKEN or BEARER_TOKENS to require bearer auth for facilitator startup." + ); +} // Database setup (optional) let pool = DATABASE_URL @@ -53,7 +71,17 @@ const tracking = createTracking(pgClient); // Facilitator + App const facilitator = createFacilitator({ ...defaultSigners }); -const app = createApp({ facilitator, tracking }); +const app = createApp({ + facilitator, + tracking, + modules: [ + createBearerTokenModule({ + tokens: TOKENS, + protectedPaths: ["/verify", "/settle"], + realm: "facilitator", + }), + ], +}); app.listen(PORT); console.log(`x402 Facilitator listening on http://localhost:${PORT}`); diff --git a/examples/facilitator-server/src/modules/bearer-token.ts b/examples/facilitator-server/src/modules/bearer-token.ts new file mode 100644 index 00000000..4bdf1901 --- /dev/null +++ b/examples/facilitator-server/src/modules/bearer-token.ts @@ -0,0 +1,86 @@ +import { Elysia } from "elysia"; + +export interface BearerTokenModuleConfig { + tokens: string[]; + protectedPaths?: string[]; + realm?: string; +} + +const DEFAULT_PROTECTED_PATHS = ["/verify", "/settle"]; +const DEFAULT_REALM = "facilitator"; + +function normalizePath(path: string): string { + if (!path || path === "/") return "/"; + return path.endsWith("/") ? path.slice(0, -1) : path; +} + +function parseRequestPath(request: Request, fallbackPath?: string): string { + const rawUrl = request.url || ""; + try { + return normalizePath(new URL(rawUrl).pathname); + } catch { + try { + return normalizePath(new URL(rawUrl, "http://localhost").pathname); + } catch { + return normalizePath(fallbackPath ?? "/"); + } + } +} + +function parseBearerAuthorizationHeader(headerValue: string): string | undefined { + const match = /^Bearer\s+(.+)$/i.exec(headerValue.trim()); + if (!match) return undefined; + const token = match[1]?.trim(); + return token || undefined; +} + +function matchesProtectedPath(path: string, protectedPaths: string[]): boolean { + return protectedPaths.some( + (protectedPath) => + path === protectedPath || path.startsWith(`${protectedPath}/`) + ); +} + +function escapeRealm(realm: string): string { + return realm.replace(/"/g, '\\"'); +} + +export function createBearerTokenModule( + config: BearerTokenModuleConfig +): unknown { + const tokens = config.tokens.map((token) => token.trim()).filter(Boolean); + if (!tokens.length) { + throw new Error("Bearer auth requires at least one token."); + } + + const realm = config.realm ?? DEFAULT_REALM; + const protectedPaths = (config.protectedPaths ?? DEFAULT_PROTECTED_PATHS).map( + normalizePath + ); + const validTokens = new Set(tokens); + const challengeHeader = `Bearer realm="${escapeRealm(realm)}"`; + + return (app: Elysia) => + app.onBeforeHandle(({ request, path, set }) => { + const requestPath = parseRequestPath(request, path); + if (!matchesProtectedPath(requestPath, protectedPaths)) { + return; + } + + const authorizationHeader = request.headers.get("authorization"); + const token = authorizationHeader + ? parseBearerAuthorizationHeader(authorizationHeader) + : undefined; + + if (token && validTokens.has(token)) { + return; + } + + set.status = 401; + set.headers["www-authenticate"] = challengeHeader; + return { + error: "Unauthorized", + message: "Valid Bearer token is required", + }; + }); +} diff --git a/examples/facilitator-server/tests/app.test.ts b/examples/facilitator-server/tests/app.test.ts index 0bf46e38..1b092eb9 100644 --- a/examples/facilitator-server/tests/app.test.ts +++ b/examples/facilitator-server/tests/app.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { createApp } from "../src/app.js"; import { createTracking } from "../src/db.js"; import type { AppConfig } from "../src/app.js"; +import { createBearerTokenModule } from "../src/modules/bearer-token.js"; let tracking: ReturnType; let app: ReturnType; @@ -119,3 +120,33 @@ describe("/verify tracking with missing body", () => { expect(record.paymentVerified).toBe(false); }); }); + +describe("bearer token module integration", () => { + it("rejects /verify without authorization header when module is configured", async () => { + const guardedApp = createApp({ + facilitator: mockFacilitator, + tracking, + modules: [ + createBearerTokenModule({ + tokens: ["DREAMS"], + }), + ], + }); + + const response = await guardedApp.handle( + new Request("http://localhost/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + paymentPayload, + paymentRequirements, + }), + }) + ); + + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toBe( + 'Bearer realm="facilitator"' + ); + }); +}); diff --git a/examples/facilitator-server/tests/bearer-token-module.test.ts b/examples/facilitator-server/tests/bearer-token-module.test.ts new file mode 100644 index 00000000..f163b1e5 --- /dev/null +++ b/examples/facilitator-server/tests/bearer-token-module.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "bun:test"; +import { Elysia } from "elysia"; +import { createBearerTokenModule } from "../src/modules/bearer-token.js"; + +describe("createBearerTokenModule", () => { + it("allows a protected route with a valid bearer token", async () => { + const app = new Elysia() + .use( + createBearerTokenModule({ + tokens: ["DREAMS"], + protectedPaths: ["/verify"], + }) + ) + .post("/verify", () => ({ ok: true })); + + const response = await app.handle( + new Request("http://localhost/verify", { + method: "POST", + headers: { + Authorization: "Bearer DREAMS", + }, + }) + ); + + expect(response.status).toBe(200); + }); + + it("does not require auth for unprotected routes", async () => { + const app = new Elysia() + .use( + createBearerTokenModule({ + tokens: ["DREAMS"], + protectedPaths: ["/verify"], + }) + ) + .get("/supported", () => ({ ok: true })); + + const response = await app.handle( + new Request("http://localhost/supported", { + method: "GET", + }) + ); + + expect(response.status).toBe(200); + }); + + it("rejects invalid bearer tokens for protected routes", async () => { + const app = new Elysia() + .use( + createBearerTokenModule({ + tokens: ["DREAMS"], + protectedPaths: ["/settle"], + }) + ) + .post("/settle", () => ({ ok: true })); + + const response = await app.handle( + new Request("http://localhost/settle", { + method: "POST", + headers: { + Authorization: "Bearer NOPE", + }, + }) + ); + + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toBe( + 'Bearer realm="facilitator"' + ); + }); +});