diff --git a/.changeset/thick-icons-greet.md b/.changeset/thick-icons-greet.md new file mode 100644 index 00000000..a48d82f8 --- /dev/null +++ b/.changeset/thick-icons-greet.md @@ -0,0 +1,5 @@ +--- +"@monetizationos/proxy": minor +--- + +Forward permission-downgraded API key to mos-api/ and mos-endpoints/ paths, and add support for identity details for custom endpoints, to be consumed by MOS in the future. diff --git a/src/MOSProxy.ts b/src/MOSProxy.ts index 983bc00e..4421b47e 100644 --- a/src/MOSProxy.ts +++ b/src/MOSProxy.ts @@ -108,7 +108,7 @@ export class MOSProxy { if (!apiFetcher) { throw new Error('MOSProxy: customEndpoints is enabled but no API fetcher is configured') } - const customEndpointResponse = await customEndpointRequest(ctx, request, apiFetcher) + const customEndpointResponse = await customEndpointRequest(ctx, request, identityProvider, clientMetadataProvider, apiFetcher) if (customEndpointResponse) { return customEndpointResponse } diff --git a/src/MOSProxyBuilder.ts b/src/MOSProxyBuilder.ts index b707c7d9..91fd981b 100644 --- a/src/MOSProxyBuilder.ts +++ b/src/MOSProxyBuilder.ts @@ -235,6 +235,21 @@ export class MOSProxyBuilder { method: 'POST', mosPath: '/api/v1/offer-redemptions', }, + { + matchPath: '/mos-api/access-checks', + method: 'POST', + mosPath: '/api/v1/access-checks', + }, + { + matchPath: '/mos-api/surface-decisions', + method: 'POST', + mosPath: '/api/v1/surface-decisions', + }, + { + matchPath: '/mos-api/counter-updates', + method: 'POST', + mosPath: '/api/v1/counter-updates', + }, ], } return new MOSProxy(opts) diff --git a/src/stages/customEndpoint.ts b/src/stages/customEndpoint.ts index 352a0df3..d227dc7d 100644 --- a/src/stages/customEndpoint.ts +++ b/src/stages/customEndpoint.ts @@ -1,9 +1,16 @@ +import { type ClientMetadataProvider, defaultResolveIdentity, type IdentityProvider } from '../adapters' import type { Fetcher } from '../adapters/Fetcher' import { setMosProxyHeaders } from '../apiRequestHeaders' import type { PipelineContext } from '../context' -export default async function customEndpointRequest(ctx: PipelineContext, request: Request, apiFetcher: Fetcher): Promise { - const { config } = ctx +export default async function customEndpointRequest( + ctx: PipelineContext, + request: Request, + identityProvider: IdentityProvider | null, + clientMetadataProvider: ClientMetadataProvider | null, + apiFetcher: Fetcher, +): Promise { + const { config, logger } = ctx const requestUrl = new URL(request.url) const prefix = config.mosEndpointsPrefix @@ -17,7 +24,12 @@ export default async function customEndpointRequest(ctx: PipelineContext, reques target.port = config.mosHost.port target.pathname = `/api/v1/envs/${config.mosEnvironment}/endpoints/${requestUrl.pathname.slice(prefix.length).replace(/^\//, '')}` + const identity = await (identityProvider?.resolve ?? defaultResolveIdentity)({ request, config, logger }) + const clientMetadata = clientMetadataProvider?.build(request) ?? {} + const apiRequest = new Request(target, request) setMosProxyHeaders(apiRequest.headers) + apiRequest.headers.set('x-mos-key', `PublicBearer ${config.mosSecretKey}`) + apiRequest.headers.set('x-mos-endpoint-request-context', JSON.stringify({ identity, clientMetadata })) return apiFetcher(apiRequest) } diff --git a/src/stages/mosAuthenticatedApi.ts b/src/stages/mosAuthenticatedApi.ts index 1534c9a3..34c1edc6 100644 --- a/src/stages/mosAuthenticatedApi.ts +++ b/src/stages/mosAuthenticatedApi.ts @@ -75,7 +75,7 @@ const handleMosAuthenticatedApiRoute = async ( }), headers: withMosProxyHeaders({ 'Content-Type': 'application/json', - Authorization: `Bearer ${config.mosSecretKey}`, + Authorization: `PublicBearer ${config.mosSecretKey}`, }), }), ) diff --git a/test/MOSProxy.spec.ts b/test/MOSProxy.spec.ts index cde8aba5..8960186a 100644 --- a/test/MOSProxy.spec.ts +++ b/test/MOSProxy.spec.ts @@ -598,7 +598,12 @@ describe('custom authenticated API routes', () => { beforeEach(() => requestCaptor.mockReset()) - it('forwards matching offer redemption request', async () => { + it.each([ + '/offer-redemptions', + '/surface-decisions', + '/access-checks', + '/counter-updates', + ])('forwards matching %s request', async (path) => { const proxy = new MOSProxyBuilder() .withConfig(baseConfig) .withApiFetcher(apiFetcher) @@ -608,9 +613,9 @@ describe('custom authenticated API routes', () => { .build() const response = await proxy.handle( - new Request('https://proxy.example.com/mos-api/offer-redemptions', { + new Request(`https://proxy.example.com/mos-api${path}`, { method: 'POST', - body: JSON.stringify({ offerToken: 'offer.abc' }), + body: JSON.stringify({ bodyProperty: 'value' }), headers: { cookie: 'anon-session=the-session;', }, @@ -620,16 +625,16 @@ describe('custom authenticated API routes', () => { expect(response.status).toBe(200) expect(await response.json()).toEqual({ success: true }) expect(requestCaptor).toHaveBeenCalledExactlyOnceWith( - 'https://api.monetizationos.com/api/v1/offer-redemptions', // + `https://api.monetizationos.com/api/v1${path}`, // 'POST', { custom: 'metadata', - http: { url: 'https://proxy.example.com/mos-api/offer-redemptions' }, + http: { url: `https://proxy.example.com/mos-api${path}` }, identity: { anonymousIdentifier: 'the-session' }, - offerToken: 'offer.abc', + bodyProperty: 'value', }, [ - ['authorization', 'Bearer sk_env_test_abc'], + ['authorization', 'PublicBearer sk_env_test_abc'], ['content-type', 'application/json'], ['x-mos-proxy-client', expect.any(String)], ['x-mos-proxy-version', expect.any(String)], @@ -770,7 +775,7 @@ describe('custom authenticated API routes', () => { prop: 'value', }, [ - ['authorization', 'Bearer sk_env_test_abc'], + ['authorization', 'PublicBearer sk_env_test_abc'], ['content-type', 'application/json'], ['x-mos-proxy-client', expect.any(String)], ['x-mos-proxy-version', expect.any(String)], diff --git a/test/stages/customEndpoint.spec.ts b/test/stages/customEndpoint.spec.ts index e1b32917..9c9b49d9 100644 --- a/test/stages/customEndpoint.spec.ts +++ b/test/stages/customEndpoint.spec.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import type { ClientMetadataProvider, IdentityProvider } from '../../src' import { MOS_PROXY_CLIENT_HEADER, MOS_PROXY_PACKAGE_VERSION, MOS_PROXY_VERSION_HEADER } from '../../src/apiRequestHeaders' import { type MOSConfig, normalizeMOSConfig } from '../../src/config' import type { PipelineContext } from '../../src/context' @@ -19,17 +20,27 @@ const rawBaseConfig: MOSConfigInput = { const silentLogger: MOSProxyLogger = { log() {} } const ctx = (config: MOSConfig): PipelineContext => ({ config, logger: silentLogger }) const baseConfig = normalizeMOSConfig(rawBaseConfig) +const clientMetadataProvider: ClientMetadataProvider = { build: vi.fn() } +const identityProvider: IdentityProvider = { resolve: vi.fn() } describe('customEndpointRequest', () => { it('returns null for paths outside the prefix', async () => { const fetcher = MockFetcher(() => new Response('should not be called', { status: 500 })) - const result = await customEndpointRequest(ctx(baseConfig), new Request('https://proxy.example.com/other/thing'), fetcher) + const result = await customEndpointRequest( + ctx(baseConfig), + new Request('https://proxy.example.com/other/thing'), + identityProvider, + clientMetadataProvider, + fetcher, + ) expect(result).toBeNull() expect(fetcher.calls.length).toBe(0) }) it('rewrites the request URL onto the MOS API host using the env extracted from the secret key', async () => { const fetcher = MockFetcher((req) => new Response(`got ${req.url}`, { status: 200 })) + vi.spyOn(clientMetadataProvider, 'build').mockReturnValue({ prop: 'value' }) + vi.spyOn(identityProvider, 'resolve').mockReturnValue({ anonymousIdentifier: 'anon_123' }) const response = await customEndpointRequest( ctx(baseConfig), @@ -38,6 +49,8 @@ describe('customEndpointRequest', () => { body: 'payload', headers: { [MOS_PROXY_VERSION_HEADER]: 'spoofed' }, }), + identityProvider, + clientMetadataProvider, fetcher, ) @@ -48,6 +61,10 @@ describe('customEndpointRequest', () => { expect(sent.method).toBe('POST') expect(sent.headers.get(MOS_PROXY_VERSION_HEADER)).toBe(MOS_PROXY_PACKAGE_VERSION) expect(sent.headers.get(MOS_PROXY_CLIENT_HEADER)).toMatch(/^runtime=/) + expect(sent.headers.get('x-mos-key')).toBe(`PublicBearer ${rawBaseConfig.mosSecretKey}`) + expect(sent.headers.get('x-mos-endpoint-request-context')).toBe( + JSON.stringify({ identity: { anonymousIdentifier: 'anon_123' }, clientMetadata: { prop: 'value' } }), + ) expect(await sent.text()).toBe('payload') }) @@ -58,6 +75,8 @@ describe('customEndpointRequest', () => { const response = await customEndpointRequest( ctx(normalizeMOSConfig(rest)), new Request('https://proxy.example.com/mos-endpoints/hello'), + null, + null, fetcher, ) @@ -71,11 +90,19 @@ describe('customEndpointRequest', () => { const response = await customEndpointRequest( ctx(normalizeMOSConfig({ ...rawBaseConfig, mosHost: 'http://api.local:8080' })), new Request('https://proxy.example.com/mos-endpoints/ping'), + null, + null, fetcher, ) expect(response).not.toBeNull() expect(response!.headers.get('x-target')).toBe('http://api.local:8080/api/v1/envs/env_test/endpoints/ping') + + const sent = fetcher.calls[0]!.request + expect(sent.headers.get('x-mos-key')).toBe(`PublicBearer ${rawBaseConfig.mosSecretKey}`) + expect(sent.headers.get('x-mos-endpoint-request-context')).toBe( + JSON.stringify({ identity: { createAnonymousIdentifier: true }, clientMetadata: {} }), + ) }) it('handles a secret key without enough segments by producing an underscore env path', async () => { @@ -84,6 +111,8 @@ describe('customEndpointRequest', () => { const response = await customEndpointRequest( ctx(normalizeMOSConfig({ ...rawBaseConfig, mosSecretKey: 'oops' })), new Request('https://proxy.example.com/mos-endpoints/ping'), + identityProvider, + clientMetadataProvider, fetcher, )