diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 39cd7b7ba0..ca3f01e72c 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -101,7 +101,10 @@ 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' +import { + getTenantFromApiSignature, + TenantApiSignatureResult +} from './shared/utils' export interface AppContextData { logger: Logger container: AppContainer @@ -228,6 +231,11 @@ type ContextType = T extends ( const WALLET_ADDRESS_PATH = '/:walletAddressPath+' +export interface TenantedApolloContext extends ApolloContext { + tenant?: Tenant + isOperator: boolean +} + export interface AppServices { logger: Promise telemetry: Promise @@ -397,19 +405,23 @@ export class App { } ) + let tenantApiSignatureResult: TenantApiSignatureResult koa.use( async (ctx: TenantedHttpSigContext, next: Koa.Next): Promise => { - if (!(await verifyTenantOrOperatorApiSignature(ctx, this.config))) { + const result = await getTenantFromApiSignature(ctx, this.config) + if (!result) { ctx.throw(401, 'Unauthorized') } + tenantApiSignatureResult = result return next() } ) koa.use( koaMiddleware(this.apolloServer, { - context: async (): Promise => { + context: async (): Promise => { return { + ...tenantApiSignatureResult, container: this.container, logger: await this.container.use('logger') } diff --git a/packages/backend/src/shared/utils.test.ts b/packages/backend/src/shared/utils.test.ts index 4b8d7b8ea7..40b3657ca3 100644 --- a/packages/backend/src/shared/utils.test.ts +++ b/packages/backend/src/shared/utils.test.ts @@ -3,12 +3,13 @@ import { IocContract } from '@adonisjs/fold' import { Redis } from 'ioredis' import { faker } from '@faker-js/faker' import { v4 } from 'uuid' +import assert from 'assert' import { isValidHttpUrl, poll, requestWithTimeout, sleep, - verifyTenantOrOperatorApiSignature + getTenantFromApiSignature } from './utils' import { AppServices, AppContext, TenantedHttpSigContext } from '../app' import { TestContainer, createTestApp } from '../tests/app' @@ -322,7 +323,7 @@ describe('utils', (): void => { ${false} | ${'tenanted non-operator'} ${true} | ${'tenanted operator'} `( - 'returns true if $description request has valid signature', + 'returns if $description request has valid signature', async ({ isOperator }): Promise => { const requestBody = { test: 'value' } @@ -352,20 +353,19 @@ describe('utils', (): void => { ) ctx.request.body = requestBody - const result = await verifyTenantOrOperatorApiSignature(ctx, config) - expect(result).toEqual(true) - - expect(ctx.tenant).toEqual(isOperator ? operator : tenant) + const result = await getTenantFromApiSignature(ctx, config) + assert.ok(result) + expect(result.tenant).toEqual(isOperator ? operator : tenant) if (isOperator) { - expect(ctx.isOperator).toEqual(true) + expect(result.isOperator).toEqual(true) } else { - expect(ctx.isOperator).toEqual(false) + expect(result.isOperator).toEqual(false) } } ) - test("returns false when signature isn't signed with tenant secret", async (): Promise => { + test("returns undefined when signature isn't signed with tenant secret", async (): Promise => { const requestBody = { test: 'value' } const signature = generateApiSignature( 'wrongsecret', @@ -386,13 +386,11 @@ describe('utils', (): void => { ) ctx.request.body = requestBody - const result = await verifyTenantOrOperatorApiSignature(ctx, config) - expect(result).toEqual(false) - expect(ctx.tenant).toBeUndefined() - expect(ctx.isOperator).toEqual(false) + const result = await getTenantFromApiSignature(ctx, config) + expect(result).toBeUndefined }) - test('returns false if tenant id is not included', async (): Promise => { + test('returns undefined if tenant id is not included', async (): Promise => { const requestBody = { test: 'value' } const signature = generateApiSignature( tenant.apiSecret, @@ -413,13 +411,11 @@ describe('utils', (): void => { ctx.request.body = requestBody - const result = await verifyTenantOrOperatorApiSignature(ctx, config) - expect(result).toEqual(false) - expect(ctx.tenant).toBeUndefined() - expect(ctx.isOperator).toEqual(false) + const result = await getTenantFromApiSignature(ctx, config) + expect(result).toBeUndefined() }) - test('returns false if tenant does not exist', async (): Promise => { + test('returns undefined if tenant does not exist', async (): Promise => { const requestBody = { test: 'value' } const signature = generateApiSignature( tenant.apiSecret, @@ -443,11 +439,9 @@ describe('utils', (): void => { const tenantService = await deps.use('tenantService') const getSpy = jest.spyOn(tenantService, 'get') - const result = await verifyTenantOrOperatorApiSignature(ctx, config) - expect(result).toEqual(false) + const result = await getTenantFromApiSignature(ctx, config) + expect(result).toBeUndefined() expect(getSpy).toHaveBeenCalled() - 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 be5a2753ea..3fc86b9834 100644 --- a/packages/backend/src/shared/utils.ts +++ b/packages/backend/src/shared/utils.ts @@ -4,6 +4,7 @@ import { createHmac } from 'crypto' import { canonicalize } from 'json-canonicalize' import { IAppConfig } from '../config/app' import { AppContext, TenantedHttpSigContext } from '../app' +import { Tenant } from '../tenants/model' export function validateId(id: string): boolean { return validate(id) && version(id) === 4 @@ -172,35 +173,40 @@ async function canApiSignatureBeProcessed( return true } +export interface TenantApiSignatureResult { + tenant?: Tenant + isOperator: boolean +} + /* 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. + associated with a tenant id in the headers. If a tenant secret can replicate the signature, the request is tenanted to that particular tenant. If the environment admin secret matches the tenant's secret, then it is an operator request with elevated permissions. If neither can replicate the signature then it is unauthorized. */ -export async function verifyTenantOrOperatorApiSignature( +export async function getTenantFromApiSignature( ctx: TenantedHttpSigContext, config: IAppConfig -): Promise { +): Promise { ctx.tenant = undefined ctx.isOperator = false const { headers } = ctx.request const signature = headers['signature'] if (!signature) { - return false + return undefined } const tenantService = await ctx.container.use('tenantService') const tenantId = headers['tenant-id'] const tenant = tenantId ? await tenantService.get(tenantId) : undefined - if (!tenant) return false + if (!tenant) return undefined - if (!(await canApiSignatureBeProcessed(signature, ctx, config))) return false + if (!(await canApiSignatureBeProcessed(signature, ctx, config))) + return undefined - // First, try validating with the tenant api secret if ( tenant.apiSecret && verifyApiSignatureDigest( @@ -210,12 +216,10 @@ export async function verifyTenantOrOperatorApiSignature( tenant.apiSecret ) ) { - ctx.tenant = tenant - ctx.isOperator = tenant.apiSecret === config.adminApiSecret - return true + return { tenant, isOperator: tenant.apiSecret === config.adminApiSecret } } - return false + return undefined } export async function verifyApiSignature(