diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 3bbe8d2662..c2f73b2396 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -85,7 +85,6 @@ import { IlpPaymentService } from './payment-method/ilp/service' import { TelemetryService } from './telemetry/service' import { ApolloArmor } from '@escape.tech/graphql-armor' import { openPaymentsServerErrorMiddleware } from './open_payments/route-errors' -import { verifyApiSignature } from './shared/utils' import { WalletAddress } from './open_payments/wallet_address/model' import { getWalletAddressUrlFromIncomingPayment, @@ -101,6 +100,8 @@ import { LoggingPlugin } from './graphql/plugin' import { LocalPaymentService } from './payment-method/local/service' import { GrantService } from './open_payments/grant/service' import { AuthServerService } from './open_payments/authServer/service' +import { Tenant } from './tenants/model' +import { verifyTenantOrOperatorApiSignature } from './shared/utils' export interface AppContextData { logger: Logger container: AppContainer @@ -144,6 +145,19 @@ export type HttpSigContext = AppContext & { client: string } +type TenantedHttpSigHeaders = HttpSigHeaders & Record<'tenantId', string> + +type TenantedHttpSigRequest = Omit & { + headers: TenantedHttpSigHeaders +} + +export type TenantedHttpSigContext = HttpSigContext & { + headers: TenantedHttpSigHeaders + request: TenantedHttpSigRequest + tenant?: Tenant + isOperator: boolean +} + export type HttpSigWithAuthenticatedStatusContext = HttpSigContext & AuthenticatedStatusContext @@ -384,12 +398,14 @@ export class App { ) if (this.config.adminApiSecret) { - koa.use(async (ctx, next: Koa.Next): Promise => { - if (!verifyApiSignature(ctx, this.config)) { - ctx.throw(401, 'Unauthorized') + koa.use( + async (ctx: TenantedHttpSigContext, next: Koa.Next): Promise => { + if (!verifyTenantOrOperatorApiSignature(ctx, this.config)) { + ctx.throw(401, 'Unauthorized') + } + return next() } - return next() - }) + ) } koa.use( diff --git a/packages/backend/src/shared/utils.test.ts b/packages/backend/src/shared/utils.test.ts index b786ef8498..5540b4a178 100644 --- a/packages/backend/src/shared/utils.test.ts +++ b/packages/backend/src/shared/utils.test.ts @@ -1,13 +1,23 @@ +import crypto from 'crypto' import { IocContract } from '@adonisjs/fold' import { Redis } from 'ioredis' -import { isValidHttpUrl, poll, requestWithTimeout, sleep } from './utils' -import { AppServices, AppContext } from '../app' +import { faker } from '@faker-js/faker' +import { + isValidHttpUrl, + poll, + requestWithTimeout, + sleep, + verifyTenantOrOperatorApiSignature +} from './utils' +import { AppServices, AppContext, TenantedHttpSigContext } from '../app' import { TestContainer, createTestApp } from '../tests/app' import { initIocContainer } from '..' import { verifyApiSignature } from './utils' import { generateApiSignature } from '../tests/apiSignature' -import { Config } from '../config/app' +import { Config, IAppConfig } from '../config/app' import { createContext } from '../tests/context' +import { Tenant } from '../tenants/model' +import { truncateTables } from '../tests/tableManager' describe('utils', (): void => { describe('isValidHttpUrl', (): void => { @@ -258,4 +268,131 @@ describe('utils', (): void => { expect(verified).toBe(false) }) }) + + describe('tenant/operator admin api signatures', (): void => { + let deps: IocContract + let appContainer: TestContainer + let tenant: Tenant + let config: IAppConfig + let redis: Redis + + const operatorApiSecret = 'test-operator-secret' + + beforeAll(async (): Promise => { + deps = initIocContainer({ + ...Config, + adminApiSecret: operatorApiSecret + }) + appContainer = await createTestApp(deps) + config = await deps.use('config') + redis = await deps.use('redis') + }) + + beforeEach(async (): Promise => { + tenant = await Tenant.query(appContainer.knex).insertAndFetch({ + email: faker.internet.email(), + publicName: faker.company.name(), + apiSecret: crypto.randomBytes(8).toString('base64'), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + }) + }) + + afterEach(async (): Promise => { + await redis.flushall() + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + test.each` + tenanted | isOperator | description + ${true} | ${false} | ${'tenanted non-operator'} + ${true} | ${true} | ${'tenanted operator'} + ${false} | ${true} | ${'non-tenanted operator'} + `( + 'returns true if $description request has valid signature', + async ({ tenanted, isOperator }): Promise => { + const requestBody = { test: 'value' } + + const signature = isOperator + ? generateApiSignature( + config.adminApiSecret as string, + Config.adminApiSignatureVersion, + requestBody + ) + : generateApiSignature( + tenant.apiSecret, + Config.adminApiSignatureVersion, + requestBody + ) + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + signature, + tenantId: tenanted ? tenant.id : undefined + }, + url: '/graphql' + }, + {}, + appContainer.container + ) + ctx.request.body = requestBody + + const result = await verifyTenantOrOperatorApiSignature(ctx, config) + expect(result).toEqual(true) + + if (tenanted) { + expect(ctx.tenant).toEqual(tenant) + } else { + expect(ctx.tenant).toBeUndefined() + } + + if (isOperator) { + expect(ctx.isOperator).toEqual(true) + } else { + expect(ctx.isOperator).toEqual(false) + } + } + ) + + test.each` + failurePoint + ${'tenant'} + ${'operator'} + `( + "returns false when $failurePoint signature isn't valid", + async ({ failurePoint }): Promise => { + const tenantedRequest = failurePoint === 'tenant' + const requestBody = { test: 'value' } + const signature = generateApiSignature( + 'wrongsecret', + Config.adminApiSignatureVersion, + requestBody + ) + const ctx = createContext( + { + headers: { + Accept: 'application/json', + signature, + tenantId: tenantedRequest ? tenant.id : undefined + }, + url: '/graphql' + }, + {}, + appContainer.container + ) + ctx.request.body = requestBody + + const result = await verifyTenantOrOperatorApiSignature(ctx, config) + expect(result).toEqual(false) + expect(ctx.tenant).toBeUndefined() + expect(ctx.isOperator).toEqual(false) + } + ) + }) }) diff --git a/packages/backend/src/shared/utils.ts b/packages/backend/src/shared/utils.ts index 3f34098523..62132c5116 100644 --- a/packages/backend/src/shared/utils.ts +++ b/packages/backend/src/shared/utils.ts @@ -3,7 +3,7 @@ import { URL, type URL as URLType } from 'url' import { createHmac } from 'crypto' import { canonicalize } from 'json-canonicalize' import { IAppConfig } from '../config/app' -import { AppContext } from '../app' +import { AppContext, TenantedHttpSigContext } from '../app' export function validateId(id: string): boolean { return validate(id) && version(id) === 4 @@ -126,7 +126,8 @@ function getSignatureParts(signature: string) { function verifyApiSignatureDigest( signature: string, request: AppContext['request'], - config: IAppConfig + config: IAppConfig, + secret: string ): boolean { const { body } = request const { @@ -140,7 +141,7 @@ function verifyApiSignatureDigest( } const payload = `${timestamp}.${canonicalize(body)}` - const hmac = createHmac('sha256', config.adminApiSecret as string) + const hmac = createHmac('sha256', secret) hmac.update(payload) const digest = hmac.digest('hex') @@ -171,6 +172,64 @@ async function canApiSignatureBeProcessed( return true } +/* + Verifies http signatures by first attempting to replicate it with a secret + associated with a tenant id in the headers, then with the configured admin secret. + + If a tenant secret can replicate the signature, the request is tenanted to that particular tenant. + If the environment admin secret replicates the signature, then it is an operator request with elevated permissions. + If neither can replicate the signature then it is unauthorized. +*/ +export async function verifyTenantOrOperatorApiSignature( + ctx: TenantedHttpSigContext, + config: IAppConfig +): Promise { + ctx.tenant = undefined + ctx.isOperator = false + const { headers } = ctx.request + const signature = headers['signature'] + if (!signature) { + return false + } + + const tenantService = await ctx.container.use('tenantService') + const tenantId = headers['tenantid'] + const tenant = tenantId ? await tenantService.get(tenantId) : undefined + + if (!(await canApiSignatureBeProcessed(signature as string, ctx, config))) + return false + + // First, try validating with the tenant api secret + if ( + tenant?.apiSecret && + verifyApiSignatureDigest( + signature as string, + ctx.request, + config, + tenant.apiSecret + ) + ) { + ctx.tenant = tenant + return true + } + + // Fall back on validating with operator api secret if prior validation fails + if ( + verifyApiSignatureDigest( + signature as string, + ctx.request, + config, + config.adminApiSecret as string + ) + ) { + ctx.tenant = tenant + ctx.isOperator = true + return true + } + + return false +} + export async function verifyApiSignature( ctx: AppContext, config: IAppConfig @@ -184,5 +243,10 @@ export async function verifyApiSignature( if (!(await canApiSignatureBeProcessed(signature as string, ctx, config))) return false - return verifyApiSignatureDigest(signature as string, ctx.request, config) + return verifyApiSignatureDigest( + signature as string, + ctx.request, + config, + config.adminApiSecret as string + ) } diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts index 84fad049ca..9efb7f1717 100644 --- a/packages/backend/src/tenants/service.test.ts +++ b/packages/backend/src/tenants/service.test.ts @@ -61,7 +61,7 @@ describe('Tenant Service', (): void => { await appContainer.shutdown() }) - describe('Tenant pangination', (): void => { + describe('Tenant pagination', (): void => { describe('getPage', (): void => { getPageTests({ createModel: () => createTenant(deps), diff --git a/packages/backend/src/tests/tenant.ts b/packages/backend/src/tests/tenant.ts index 4ac1488b84..f174a58f2f 100644 --- a/packages/backend/src/tests/tenant.ts +++ b/packages/backend/src/tests/tenant.ts @@ -1,3 +1,4 @@ +import nock from 'nock' import { IocContract } from '@adonisjs/fold' import { faker } from '@faker-js/faker' import { AppServices } from '../app' @@ -11,8 +12,6 @@ interface CreateOptions { idpSecret: string } -const nock = (global as unknown as { nock: typeof import('nock') }).nock - export async function createTenant( deps: IocContract, options?: CreateOptions