Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/thick-icons-greet.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion src/MOSProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
15 changes: 15 additions & 0 deletions src/MOSProxyBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 14 additions & 2 deletions src/stages/customEndpoint.ts
Original file line number Diff line number Diff line change
@@ -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<Response | null> {
const { config } = ctx
export default async function customEndpointRequest(
ctx: PipelineContext,
request: Request,
identityProvider: IdentityProvider | null,
clientMetadataProvider: ClientMetadataProvider | null,
apiFetcher: Fetcher,
): Promise<Response | null> {
const { config, logger } = ctx
const requestUrl = new URL(request.url)
const prefix = config.mosEndpointsPrefix

Expand All @@ -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 }))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And maybe user agent? Middleware might use it. The authenticatedApi thing gets it from request.headers.get('User-Agent').

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

user agent, and all the other headers, will be as-is from the original request :)

return apiFetcher(apiRequest)
}
2 changes: 1 addition & 1 deletion src/stages/mosAuthenticatedApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const handleMosAuthenticatedApiRoute = async (
}),
headers: withMosProxyHeaders({
'Content-Type': 'application/json',
Authorization: `Bearer ${config.mosSecretKey}`,
Authorization: `PublicBearer ${config.mosSecretKey}`,
Comment thread
heinvde-mos marked this conversation as resolved.
}),
}),
)
Expand Down
21 changes: 13 additions & 8 deletions test/MOSProxy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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;',
},
Expand All @@ -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)],
Expand Down Expand Up @@ -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)],
Expand Down
33 changes: 31 additions & 2 deletions test/stages/customEndpoint.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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),
Expand All @@ -38,6 +49,8 @@ describe('customEndpointRequest', () => {
body: 'payload',
headers: { [MOS_PROXY_VERSION_HEADER]: 'spoofed' },
}),
identityProvider,
clientMetadataProvider,
fetcher,
)

Expand All @@ -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')
})

Expand All @@ -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,
)

Expand All @@ -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 () => {
Expand All @@ -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,
)

Expand Down