diff --git a/.changeset/loud-stars-joke.md b/.changeset/loud-stars-joke.md new file mode 100644 index 00000000..42f15d40 --- /dev/null +++ b/.changeset/loud-stars-joke.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added scope-bound challenge metadata for route replay protection, scope-aware `verifyCredential()` checks, and adapter auto-scoping for Hono and proxy routes. diff --git a/src/middlewares/hono.test.ts b/src/middlewares/hono.test.ts index a5bcce31..92d54ceb 100644 --- a/src/middlewares/hono.test.ts +++ b/src/middlewares/hono.test.ts @@ -1,6 +1,6 @@ import { serve } from '@hono/node-server' import { Hono } from 'hono' -import { Receipt } from 'mppx' +import { Challenge, Credential, Method, Receipt, z } from 'mppx' import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client' import { Mppx, discovery } from 'mppx/hono' import { tempo as tempo_server } from 'mppx/server' @@ -26,6 +26,40 @@ function createServer(app: Hono) { const secretKey = 'test-secret-key' +const scopeMethod = Method.toServer( + Method.from({ + name: 'mock', + intent: 'charge', + schema: { + credential: { payload: z.object({ token: z.string() }) }, + request: z.object({ + amount: z.string(), + currency: z.string(), + decimals: z.number(), + recipient: z.string(), + }), + }, + }), + { + async verify() { + return { + method: 'mock', + reference: 'tx-mock', + status: 'success' as const, + timestamp: new Date().toISOString(), + } + }, + }, +) + +function createScopeHarness() { + return Mppx.create({ + methods: [scopeMethod], + realm: 'api.example.com', + secretKey, + }) +} + function createChargeHarness(feePayer: boolean) { const mppx = Mppx.create({ methods: [ @@ -126,6 +160,66 @@ describe('charge', () => { }) }) +describe('scope binding', () => { + const scopeOpts = { + amount: '1', + currency: '0x0000000000000000000000000000000000000001', + decimals: 6, + recipient: '0x0000000000000000000000000000000000000002', + } + + test('auto-injects route scope and blocks same-economics replay across routes', async () => { + const mppx = createScopeHarness() + + const app = new Hono() + app.get('/alpha/:id', mppx.charge(scopeOpts), (c) => c.json({ route: 'alpha' })) + app.get('/beta/:id', mppx.charge(scopeOpts), (c) => c.json({ route: 'beta' })) + + const server = await createServer(app) + const challengeResponse = await fetch(`${server.url}/alpha/1`) + expect(challengeResponse.status).toBe(402) + + const challenge = Challenge.fromResponse(challengeResponse) + expect(challenge.opaque).toEqual({ _mppx_scope: 'GET /alpha/:id' }) + + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + const replay = await fetch(`${server.url}/beta/1`, { + headers: { Authorization: Credential.serialize(credential) }, + }) + + expect(replay.status).toBe(402) + server.close() + }) + + test('manual scope overrides adapter-derived route scope', async () => { + const mppx = createScopeHarness() + + const app = new Hono() + app.get('/alpha/:id', mppx.charge({ ...scopeOpts, scope: 'shared-scope' }), (c) => + c.json({ route: 'alpha' }), + ) + app.get('/beta/:id', mppx.charge({ ...scopeOpts, scope: 'shared-scope' }), (c) => + c.json({ route: 'beta' }), + ) + + const server = await createServer(app) + const challengeResponse = await fetch(`${server.url}/alpha/1`) + expect(challengeResponse.status).toBe(402) + + const challenge = Challenge.fromResponse(challengeResponse) + expect(challenge.opaque).toEqual({ _mppx_scope: 'shared-scope' }) + + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + const replay = await fetch(`${server.url}/beta/2`, { + headers: { Authorization: Credential.serialize(credential) }, + }) + + expect(replay.status).toBe(200) + expect(await replay.json()).toEqual({ route: 'beta' }) + server.close() + }) +}) + describe('session', () => { let escrowContract: Address diff --git a/src/middlewares/hono.ts b/src/middlewares/hono.ts index de3deb5b..df25e1f6 100644 --- a/src/middlewares/hono.ts +++ b/src/middlewares/hono.ts @@ -1,6 +1,7 @@ import type { Hono, MiddlewareHandler } from 'hono' import { generate, type GenerateConfig, type RouteConfig } from '../discovery/OpenApi.js' +import * as Scope from '../server/internal/scope.js' import * as Mppx_core from '../server/Mppx.js' import * as Mppx_internal from './internal/mppx.js' @@ -56,7 +57,11 @@ export function payment( options: intent extends (options: infer options) => any ? options : never, ): MiddlewareHandler { return async (c, next) => { - const result = await intent(options)(c.req.raw) + const request = + options.scope === undefined && Scope.read(options.meta) === undefined + ? Scope.attach(c.req.raw, `${c.req.method.toUpperCase()} ${c.req.routePath || c.req.path}`) + : c.req.raw + const result = await intent(options)(request) if (result.status === 402) return result.challenge await next() c.res = result.withReceipt(c.res) diff --git a/src/proxy/Proxy.test.ts b/src/proxy/Proxy.test.ts index 7ee15b7d..f4365aa2 100644 --- a/src/proxy/Proxy.test.ts +++ b/src/proxy/Proxy.test.ts @@ -30,6 +30,40 @@ const mppx_server = Mppx_server.create({ secretKey, }) +const scopeMethod = Method.toServer( + Method.from({ + name: 'mock', + intent: 'charge', + schema: { + credential: { payload: z.object({ token: z.string() }) }, + request: z.object({ + amount: z.string(), + currency: z.string(), + decimals: z.number(), + recipient: z.string(), + }), + }, + }), + { + async verify() { + return { + method: 'mock', + reference: 'tx-mock', + status: 'success' as const, + timestamp: new Date().toISOString(), + } + }, + }, +) + +function createScopeServer() { + return Mppx_server.create({ + methods: [scopeMethod], + realm: 'api.example.com', + secretKey, + }) +} + const mppx_client = Mppx_client.create({ polyfill: false, methods: [ @@ -707,6 +741,88 @@ describe('create', () => { const res = await fetch(`${proxyServer.url}/api/v1/search?q=hello&limit=10`) expect(await res.json()).toEqual({ search: '?q=hello&limit=10' }) }) + + test('auto-injects proxy route scope and blocks same-economics replay across routes', async () => { + const scopedServer = createScopeServer() + const proxy = ApiProxy.create({ + services: [ + Service.from('api', { + baseUrl: 'https://api.example.com', + routes: { + 'GET /v1/alpha': scopedServer.charge({ + amount: '1', + currency: '0x0000000000000000000000000000000000000001', + decimals: 6, + recipient: '0x0000000000000000000000000000000000000002', + }), + 'GET /v1/beta': scopedServer.charge({ + amount: '1', + currency: '0x0000000000000000000000000000000000000001', + decimals: 6, + recipient: '0x0000000000000000000000000000000000000002', + }), + }, + }), + ], + }) + proxyServer = await Http.createServer(proxy.listener) + + const challengeResponse = await fetch(`${proxyServer.url}/api/v1/alpha`) + expect(challengeResponse.status).toBe(402) + + const challenge = Challenge.fromResponse(challengeResponse) + expect(challenge.opaque).toEqual({ _mppx_scope: 'GET /api/v1/alpha' }) + + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + const replay = await fetch(`${proxyServer.url}/api/v1/beta`, { + headers: { Authorization: Credential.serialize(credential) }, + }) + + expect(replay.status).toBe(402) + }) + + test('manual scope overrides proxy route scope', async () => { + const scopedServer = createScopeServer() + upstream = await createUpstream(() => Response.json({ ok: true })) + const proxy = ApiProxy.create({ + services: [ + Service.from('api', { + baseUrl: upstream.url, + routes: { + 'GET /v1/alpha': scopedServer.charge({ + amount: '1', + currency: '0x0000000000000000000000000000000000000001', + decimals: 6, + recipient: '0x0000000000000000000000000000000000000002', + scope: 'shared-scope', + }), + 'GET /v1/beta': scopedServer.charge({ + amount: '1', + currency: '0x0000000000000000000000000000000000000001', + decimals: 6, + recipient: '0x0000000000000000000000000000000000000002', + scope: 'shared-scope', + }), + }, + }), + ], + }) + proxyServer = await Http.createServer(proxy.listener) + + const challengeResponse = await fetch(`${proxyServer.url}/api/v1/alpha`) + expect(challengeResponse.status).toBe(402) + + const challenge = Challenge.fromResponse(challengeResponse) + expect(challenge.opaque).toEqual({ _mppx_scope: 'shared-scope' }) + + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + const replay = await fetch(`${proxyServer.url}/api/v1/beta`, { + headers: { Authorization: Credential.serialize(credential) }, + }) + + expect(replay.status).toBe(200) + expect(await replay.json()).toEqual({ ok: true }) + }) }) describe.runIf(isLocalnet)('plain HTTP session proxy', () => { diff --git a/src/proxy/Proxy.ts b/src/proxy/Proxy.ts index 60f0164b..0b012569 100644 --- a/src/proxy/Proxy.ts +++ b/src/proxy/Proxy.ts @@ -2,6 +2,7 @@ import type * as http from 'node:http' import * as Credential from '../Credential.js' import { generateProxy } from '../discovery/OpenApi.js' +import * as Scope from '../server/internal/scope.js' import * as Request from '../server/Request.js' import * as Headers from './internal/Headers.js' import * as Route from './internal/Route.js' @@ -129,7 +130,16 @@ export function create(config: create.Config): Proxy { if (endpoint === true) return proxyUpstream({ request, service, ctx, proxy }) const handler = typeof endpoint === 'function' ? endpoint : endpoint.pay - const result = await handler(request) + const scope = + getConfiguredScope(handler) ?? + deriveRouteScope({ + basePath: config.basePath, + routeKey: matched.key, + serviceId, + }) + const result = await handler( + getConfiguredScope(handler) ? request : Scope.attach(request, scope), + ) if (result.status === 402) return result.challenge const managementResponse = (() => { @@ -242,6 +252,22 @@ function buildDiscoveryRoutes(services: Service.Service[]) { ) } +function getConfiguredScope(handler: Service.IntentHandler): string | undefined { + if (!('_internal' in handler)) return undefined + const internal = handler._internal as { meta?: Record; scope?: string } + return Scope.read(internal.meta) ?? internal.scope +} + +function deriveRouteScope(parameters: { + basePath?: string | undefined + routeKey: string + serviceId: string +}): string { + const { basePath, routeKey, serviceId } = parameters + const { method, pattern } = Route.parseRouteKey(routeKey) + return `${method ?? '*'} ${withBasePath(basePath, `/${serviceId}${pattern}`)}` +} + function buildServiceInfo(config: create.Config): { categories?: string[]; docs?: Service.Docs } { const categories = config.categories ?? diff --git a/src/proxy/internal/Route.ts b/src/proxy/internal/Route.ts index e1bd80a1..7172928f 100644 --- a/src/proxy/internal/Route.ts +++ b/src/proxy/internal/Route.ts @@ -54,7 +54,8 @@ export function matchPath( return match } -function parseRouteKey(key: string): { method: string | undefined; pattern: string } { +/** Parses a proxy route key like `"POST /v1/messages"` into method + pathname pattern. */ +export function parseRouteKey(key: string): { method: string | undefined; pattern: string } { const tokens = key.trim().split(/\s+/) if (tokens.length >= 2 && httpMethods.has(tokens[0]!.toUpperCase())) { return { method: tokens[0]!.toUpperCase(), pattern: tokens.slice(1).join(' ') } diff --git a/src/server/Mppx.test-d.ts b/src/server/Mppx.test-d.ts index 7e06d41c..d3ee2c3f 100644 --- a/src/server/Mppx.test-d.ts +++ b/src/server/Mppx.test-d.ts @@ -169,4 +169,22 @@ describe('Mppx type tests', () => { expectTypeOf(mppx.verifyCredential).toBeFunction() }) + + test('handler options and verifyCredential accept scope', () => { + const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey }) + + expectTypeOf( + mppx.charge({ + amount: '100', + currency: '0x01', + decimals: 6, + recipient: '0x02', + scope: 'GET /premium', + }), + ).toBeFunction() + + expectTypeOf(mppx.verifyCredential('credential', { scope: 'GET /premium' })).toMatchTypeOf< + Promise + >() + }) }) diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index 536d2736..dbd2a030 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -2108,6 +2108,47 @@ describe('cross-route credential replay via scope binding flaw', () => { expect(result.status).toBe(402) }) + test('rejects same-economics credential replayed across sibling routes with different scope', async () => { + const handler = Mppx.create({ methods: [serverMethod], realm, secretKey }) + + const routeA = handler.charge({ + amount: '0.01', + currency: '0x0000000000000000000000000000000000000001', + decimals: 6, + expires: new Date(Date.now() + 60_000).toISOString(), + recipient: '0x0000000000000000000000000000000000000002', + scope: 'GET /a', + }) + const routeB = handler.charge({ + amount: '0.01', + currency: '0x0000000000000000000000000000000000000001', + decimals: 6, + expires: new Date(Date.now() + 60_000).toISOString(), + recipient: '0x0000000000000000000000000000000000000002', + scope: 'GET /b', + }) + + const routeAChallengeResult = await routeA(new Request('https://example.com/a')) + expect(routeAChallengeResult.status).toBe(402) + if (routeAChallengeResult.status !== 402) throw new Error() + + const routeAChallenge = Challenge.fromResponse(routeAChallengeResult.challenge) + expect(routeAChallenge.opaque).toEqual({ _mppx_scope: 'GET /a' }) + + const credential = Credential.from({ + challenge: routeAChallenge, + payload: { token: 'valid' }, + }) + + const result = await routeB( + new Request('https://example.com/b', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(result.status).toBe(402) + }) + test('rejects request-billed credential replayed at token-billed route', async () => { const sessionMethod = Method.from({ name: 'mock', @@ -3091,6 +3132,37 @@ describe('challenge', () => { expect(challenge.opaque).toEqual({ checkout_id: 'chk_abc' }) }) + test('challenge binds scope via reserved opaque metadata', async () => { + const mppx = Mppx.create({ + methods: [alphaChargeServer], + realm, + secretKey, + }) + + const challenge = await mppx.challenge.alpha.charge({ + ...challengeOpts, + scope: 'GET /premium', + }) + + expect(challenge.opaque).toEqual({ _mppx_scope: 'GET /premium' }) + }) + + test('scope throws when it conflicts with reserved meta scope', async () => { + const mppx = Mppx.create({ + methods: [alphaChargeServer], + realm, + secretKey, + }) + + await expect( + mppx.challenge.alpha.charge({ + ...challengeOpts, + meta: { _mppx_scope: 'GET /other' }, + scope: 'GET /premium', + }), + ).rejects.toThrow('Conflicting scope values') + }) + test('challenge applies schema transforms', async () => { // Method with a z.transform that converts decimals const transformMethod = Method.from({ @@ -3329,6 +3401,70 @@ describe('verifyCredential', () => { expect(receipt.method).toBe('alpha') }) + test('verifies a credential when the expected scope matches', async () => { + const mppx = Mppx.create({ + methods: [alphaChargeServer], + realm, + secretKey, + }) + + const challenge = await mppx.challenge.alpha.charge({ + ...challengeOpts, + scope: 'GET /premium', + }) + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + + const receipt = await mppx.verifyCredential(credential, { scope: 'GET /premium' }) + + expect(receipt.status).toBe('success') + expect(receipt.method).toBe('alpha') + }) + + test('rejects a credential when the expected scope mismatches', async () => { + const mppx = Mppx.create({ + methods: [alphaChargeServer], + realm, + secretKey, + }) + + const challenge = await mppx.challenge.alpha.charge({ + ...challengeOpts, + scope: 'GET /premium', + }) + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + + await expect(mppx.verifyCredential(credential, { scope: 'GET /other' })).rejects.toThrow( + "credential scope does not match this route's requirements", + ) + }) + + test('verifies route requirements using the echoed challenge realm when host was auto-detected', async () => { + const mppx = Mppx.create({ + methods: [alphaChargeServer], + secretKey, + }) + const request = { + amount: '1000', + currency: '0x0000000000000000000000000000000000000001', + decimals: 6, + recipient: '0x0000000000000000000000000000000000000002', + } + + const firstResult = await mppx.charge(request)(new Request('https://api.example.com/premium')) + expect(firstResult.status).toBe(402) + if (firstResult.status !== 402) throw new Error() + + const challenge = Challenge.fromResponse(firstResult.challenge) + expect(challenge.realm).toBe('api.example.com') + + const credential = Credential.from({ challenge, payload: { token: 'valid' } }) + + const receipt = await mppx.verifyCredential(credential, { request }) + + expect(receipt.status).toBe('success') + expect(receipt.method).toBe('alpha') + }) + test('verifies a credential for session intent', async () => { verifyArgs = undefined const mppx = Mppx.create({ diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 913a76f1..0654549c 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -13,12 +13,23 @@ import type * as Receipt from '../Receipt.js' import type * as z from '../zod.js' import * as Html from './internal/html/config.js' import { serviceWorker } from './internal/html/serviceWorker.gen.js' +import * as Scope from './internal/scope.js' import * as NodeListener from './NodeListener.js' import * as Request from './Request.js' import * as Transport from './Transport.js' export type Methods = readonly (Method.AnyServer | readonly Method.AnyServer[])[] +/** Options for standalone credential verification. */ +export type VerifyCredentialOptions = { + capturedRequest?: Method.CapturedRequest | undefined + meta?: Record | undefined + realm?: string | undefined + request?: Record | undefined + /** Optional expected route/resource scope bound via challenge `opaque`. */ + scope?: string | undefined +} + /** * Payment handler. */ @@ -180,13 +191,6 @@ type ChallengeFn, ) => Promise -export type VerifyCredentialOptions = { - capturedRequest?: Method.CapturedRequest | undefined - meta?: Record | undefined - realm?: string | undefined - request?: Record | undefined -} - /** * Creates a server-side payment handler from methods. * @@ -295,11 +299,24 @@ export function create< // Validate payload against method schema mi.schema.credential.payload.parse(credential.payload) + const expectedMeta = Scope.merge({ meta: options?.meta, scope: options?.scope }) + + if (options?.scope !== undefined && Scope.read(credential.challenge.opaque) !== options.scope) { + throw new Errors.InvalidChallengeError({ + id: credential.challenge.id, + reason: "credential scope does not match this route's requirements", + }) + } + const shouldValidateRoute = options?.capturedRequest !== undefined || options?.meta !== undefined || options?.realm !== undefined || options?.request !== undefined + const expectedRealm = + options?.realm ?? + realm ?? + (options?.capturedRequest === undefined ? credential.challenge.realm : undefined) const request = shouldValidateRoute ? await resolveRouteChallenge({ @@ -307,9 +324,9 @@ export function create< credential, defaults: mi.defaults, expires: credential.challenge.expires, - meta: options?.meta, + meta: expectedMeta, method: mi, - realm: options?.realm ?? realm, + realm: expectedRealm, request: mi.request as never, routeRequest: options?.request ?? {}, secretKey: secretKey!, @@ -399,13 +416,18 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R const { defaults, method, realm, respond, secretKey, transport, verify } = parameters return (options) => { - const { description, meta, ...rest } = options + const { description, meta, scope, ...rest } = options + const staticMeta = Scope.merge({ meta, scope }) return Object.assign( async (input: Transport.InputOf): Promise => { const expires = 'expires' in options ? (options.expires as string | undefined) : Expires.minutes(5) const capturedRequest = await captureRequest(transport, input) + const effectiveMeta = + scope === undefined && input instanceof globalThis.Request + ? Scope.merge({ meta: staticMeta, scope: Scope.get(input) }) + : staticMeta // Extract credential once — getCredential may have side effects (e.g. SSE transports). const [credential, credentialError] = (() => { @@ -424,7 +446,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R defaults, description, expires, - meta, + meta: effectiveMeta, method, realm, request: parameters.request, @@ -603,6 +625,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R ...method, ...defaults, ...options, + ...(staticMeta !== undefined ? { meta: staticMeta } : {}), name: method.name, intent: method.intent, _canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }), @@ -627,12 +650,14 @@ function createChallengeFn(parameters: { const { defaults, method, realm, secretKey } = parameters return async (options) => { - const { description, meta, ...rest } = options as { + const { description, meta, scope, ...rest } = options as { description?: string expires?: string meta?: Record + scope?: string [key: string]: unknown } + const effectiveMeta = Scope.merge({ meta, scope }) const expires = 'expires' in options ? (options.expires as string | undefined) : Expires.minutes(5) @@ -640,7 +665,7 @@ function createChallengeFn(parameters: { defaults, description, expires, - meta, + meta: effectiveMeta, method, realm, request: parameters.request, @@ -950,6 +975,8 @@ declare namespace MethodFn { expires?: string | undefined /** Optional server-defined correlation data (serialized as `opaque` in the request). Flat string-to-string map; clients MUST NOT modify. */ meta?: Record | undefined + /** Optional route/resource scope bound via reserved challenge metadata. */ + scope?: string | undefined } & Method.WithDefaults, defaults> export type Response = @@ -970,6 +997,7 @@ type ConfiguredHandler = ((input: Request) => Promise | undefined + scope?: string | undefined _canonicalRequest: Record } } diff --git a/src/server/internal/scope.ts b/src/server/internal/scope.ts new file mode 100644 index 00000000..18c9cefb --- /dev/null +++ b/src/server/internal/scope.ts @@ -0,0 +1,43 @@ +const requestScopes = new WeakMap() + +/** Reserved `meta` key used for mppx-managed route/resource scope binding. */ +export const reservedMetaKey = '_mppx_scope' + +/** Attaches a trusted adapter-derived scope to a Request for this process only. */ +export function attach(request: Request, scope: string): Request { + requestScopes.set(request, scope) + return request +} + +/** Reads a previously attached trusted adapter-derived scope from a Request. */ +export function get(request: Request): string | undefined { + return requestScopes.get(request) +} + +/** Returns the reserved mppx scope value from challenge metadata, if present. */ +export function read(meta: Record | undefined): string | undefined { + return meta?.[reservedMetaKey] +} + +/** + * Merges the public `scope` option into challenge metadata. + * + * Throws when both `scope` and `meta._mppx_scope` are provided with different + * values so callers have a single authoritative way to bind route scope. + */ +export function merge(parameters: { + meta?: Record | undefined + scope?: string | undefined +}): Record | undefined { + const { meta, scope } = parameters + const metaScope = read(meta) + + if (scope !== undefined && metaScope !== undefined && metaScope !== scope) { + throw new Error( + `Conflicting scope values: \`scope\` (${scope}) does not match \`meta.${reservedMetaKey}\` (${metaScope}).`, + ) + } + + if (scope === undefined || metaScope === scope) return meta + return { ...meta, [reservedMetaKey]: scope } +}