From a8b7ca4d8057d79c500f8250c22214f24361409f Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Tue, 17 Dec 2024 07:23:18 -0800 Subject: [PATCH] feat(backend): tenant signature validation for admin api (#3164) * feat(auth): tenants table v1 * feat(backend): tenant service * feat: use soft delete * feat: add idp columns to tenant model * feat: pagination tests, push deletedAt to auth api call * feat: add cache * feat(backend): tenant signature validation for admin api * fix: rebase errors * fix: remove admin api secret check from app * fix: always expect tenant id in request * chore: remove some logs * feat: await signature verification, test improvements * fix: better util parameters * fix: add tenant info to apollo context * feat: fix integration tests * fix: make tenant required on extended apollo context --- packages/backend/src/app.ts | 23 ++- packages/backend/src/shared/utils.test.ts | 190 +++++++++++++++++- packages/backend/src/shared/utils.ts | 62 +++++- packages/backend/src/tenants/service.test.ts | 15 +- packages/backend/src/tests/tenant.ts | 3 +- test/integration/lib/apollo-client.ts | 4 +- test/integration/lib/config.ts | 8 +- test/integration/lib/mock-ase.ts | 3 +- .../testenv/cloud-nine-wallet/.env | 1 + test/integration/testenv/happy-life-bank/.env | 1 + 10 files changed, 294 insertions(+), 16 deletions(-) diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index fe7b31f3c6..03fbbcf1b6 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,11 @@ 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 { + getTenantFromApiSignature, + TenantApiSignatureResult +} from './shared/utils' export interface AppContextData { logger: Logger container: AppContainer @@ -214,6 +218,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 @@ -383,10 +392,17 @@ export class App { } ) + let tenantApiSignatureResult: TenantApiSignatureResult if (this.config.env !== 'test') { koa.use(async (ctx, next: Koa.Next): Promise => { - if (!(await verifyApiSignature(ctx, this.config))) { + const result = await getTenantFromApiSignature(ctx, this.config) + if (!result) { ctx.throw(401, 'Unauthorized') + } else { + tenantApiSignatureResult = { + tenant: result.tenant, + isOperator: result.isOperator ? true : false + } } return next() }) @@ -394,8 +410,9 @@ export class App { 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 b786ef8498..409f194c4e 100644 --- a/packages/backend/src/shared/utils.test.ts +++ b/packages/backend/src/shared/utils.test.ts @@ -1,13 +1,25 @@ +import crypto from 'crypto' import { IocContract } from '@adonisjs/fold' import { Redis } from 'ioredis' -import { isValidHttpUrl, poll, requestWithTimeout, sleep } from './utils' +import { faker } from '@faker-js/faker' +import { v4 } from 'uuid' +import assert from 'assert' +import { + isValidHttpUrl, + poll, + requestWithTimeout, + sleep, + getTenantFromApiSignature +} from './utils' import { AppServices, AppContext } 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 +270,178 @@ describe('utils', (): void => { expect(verified).toBe(false) }) }) + + describe('tenant/operator admin api signatures', (): void => { + let deps: IocContract + let appContainer: TestContainer + let tenant: Tenant + let operator: Tenant + let config: IAppConfig + let redis: Redis + + const operatorApiSecret = crypto.randomBytes(8).toString('base64') + + 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' + }) + + operator = await Tenant.query(appContainer.knex).insertAndFetch({ + email: faker.internet.email(), + publicName: faker.company.name(), + apiSecret: operatorApiSecret, + 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` + isOperator | description + ${false} | ${'tenanted non-operator'} + ${true} | ${'tenanted operator'} + `( + 'returns if $description request has valid signature', + async ({ isOperator }): Promise => { + const requestBody = { test: 'value' } + + const signature = isOperator + ? generateApiSignature( + operator.apiSecret, + Config.adminApiSignatureVersion, + requestBody + ) + : generateApiSignature( + tenant.apiSecret, + Config.adminApiSignatureVersion, + requestBody + ) + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + signature, + 'tenant-id': isOperator ? operator.id : tenant.id + }, + url: '/graphql' + }, + {}, + appContainer.container + ) + ctx.request.body = requestBody + + const result = await getTenantFromApiSignature(ctx, config) + assert.ok(result) + expect(result.tenant).toEqual(isOperator ? operator : tenant) + + if (isOperator) { + expect(result.isOperator).toEqual(true) + } else { + expect(result.isOperator).toEqual(false) + } + } + ) + + test("returns undefined when signature isn't signed with tenant secret", async (): Promise => { + const requestBody = { test: 'value' } + const signature = generateApiSignature( + 'wrongsecret', + Config.adminApiSignatureVersion, + requestBody + ) + const ctx = createContext( + { + headers: { + Accept: 'application/json', + signature, + 'tenant-id': tenant.id + }, + url: '/graphql' + }, + {}, + appContainer.container + ) + ctx.request.body = requestBody + + const result = await getTenantFromApiSignature(ctx, config) + expect(result).toBeUndefined + }) + + test('returns undefined if tenant id is not included', async (): Promise => { + const requestBody = { test: 'value' } + const signature = generateApiSignature( + tenant.apiSecret, + Config.adminApiSignatureVersion, + requestBody + ) + const ctx = createContext( + { + headers: { + Accept: 'application/json', + signature + }, + url: '/graphql' + }, + {}, + appContainer.container + ) + + ctx.request.body = requestBody + + const result = await getTenantFromApiSignature(ctx, config) + expect(result).toBeUndefined() + }) + + test('returns undefined if tenant does not exist', async (): Promise => { + const requestBody = { test: 'value' } + const signature = generateApiSignature( + tenant.apiSecret, + Config.adminApiSignatureVersion, + requestBody + ) + const ctx = createContext( + { + headers: { + Accept: 'application/json', + signature, + 'tenant-id': v4() + }, + url: '/graphql' + }, + {}, + appContainer.container + ) + + ctx.request.body = requestBody + + const tenantService = await deps.use('tenantService') + const getSpy = jest.spyOn(tenantService, 'get') + const result = await getTenantFromApiSignature(ctx, config) + expect(result).toBeUndefined() + expect(getSpy).toHaveBeenCalled() + }) + }) }) diff --git a/packages/backend/src/shared/utils.ts b/packages/backend/src/shared/utils.ts index 3f34098523..abe2c1917d 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 } from '../app' +import { Tenant } from '../tenants/model' export function validateId(id: string): boolean { return validate(id) && version(id) === 4 @@ -126,7 +127,8 @@ function getSignatureParts(signature: string) { function verifyApiSignatureDigest( signature: string, request: AppContext['request'], - config: IAppConfig + adminApiSignatureVersion: number, + secret: string ): boolean { const { body } = request const { @@ -135,12 +137,12 @@ function verifyApiSignatureDigest( timestamp } = getSignatureParts(signature as string) - if (Number(signatureVersion) !== config.adminApiSignatureVersion) { + if (Number(signatureVersion) !== adminApiSignatureVersion) { return false } 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 +173,53 @@ 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. + + 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 getTenantFromApiSignature( + ctx: AppContext, + config: IAppConfig +): Promise { + const { headers } = ctx.request + const signature = headers['signature'] + if (!signature) { + 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 undefined + + if (!(await canApiSignatureBeProcessed(signature as string, ctx, config))) + return undefined + + if ( + tenant.apiSecret && + verifyApiSignatureDigest( + signature as string, + ctx.request, + config.adminApiSignatureVersion, + tenant.apiSecret + ) + ) { + return { tenant, isOperator: tenant.apiSecret === config.adminApiSecret } + } + + return undefined +} + export async function verifyApiSignature( ctx: AppContext, config: IAppConfig @@ -184,5 +233,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.adminApiSignatureVersion, + config.adminApiSecret as string + ) } diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts index 84fad049ca..da6d3b7009 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), @@ -101,6 +101,19 @@ describe('Tenant Service', (): void => { const tenant = await tenantService.get(dbTenant.id) expect(tenant).toBeUndefined() }) + + test('returns undefined if tenant is deleted', async (): Promise => { + const dbTenant = await Tenant.query(knex).insertAndFetch({ + apiSecret: 'test-secret', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret', + deletedAt: new Date() + }) + + const tenant = await tenantService.get(dbTenant.id) + expect(tenant).toBeUndefined() + }) }) describe('create', (): void => { 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 diff --git a/test/integration/lib/apollo-client.ts b/test/integration/lib/apollo-client.ts index 5cb5c06734..928b6f9b14 100644 --- a/test/integration/lib/apollo-client.ts +++ b/test/integration/lib/apollo-client.ts @@ -14,6 +14,7 @@ interface CreateApolloClientArgs { graphqlUrl: string signatureSecret: string signatureVersion: string + operatorTenantId: string } function createAuthLink(args: CreateApolloClientArgs) { @@ -35,7 +36,8 @@ function createAuthLink(args: CreateApolloClientArgs) { return { headers: { ...headers, - signature: `t=${timestamp}, v${version}=${digest}` + signature: `t=${timestamp}, v${version}=${digest}`, + 'tenant-id': args.operatorTenantId } } }) diff --git a/test/integration/lib/config.ts b/test/integration/lib/config.ts index 723a36f87d..7c51a1ecef 100644 --- a/test/integration/lib/config.ts +++ b/test/integration/lib/config.ts @@ -13,6 +13,7 @@ export type TestConfig = Config & { keyId: string signatureSecret: string signatureVersion: string + operatorTenantId: string } type EnvConfig = { @@ -26,6 +27,7 @@ type EnvConfig = { IDP_SECRET: string SIGNATURE_SECRET: string SIGNATURE_VERSION: string + OPERATOR_TENANT_ID: string } const REQUIRED_KEYS: (keyof EnvConfig)[] = [ @@ -38,7 +40,8 @@ const REQUIRED_KEYS: (keyof EnvConfig)[] = [ 'KEY_ID', 'IDP_SECRET', 'SIGNATURE_SECRET', - 'SIGNATURE_VERSION' + 'SIGNATURE_VERSION', + 'OPERATOR_TENANT_ID' ] const loadEnv = (filePath: string): EnvConfig => { @@ -78,7 +81,8 @@ const createConfig = (name: string): TestConfig => { keyId: env.KEY_ID, idpSecret: env.IDP_SECRET, signatureSecret: env.SIGNATURE_SECRET, - signatureVersion: env.SIGNATURE_VERSION + signatureVersion: env.SIGNATURE_VERSION, + operatorTenantId: env.OPERATOR_TENANT_ID } } diff --git a/test/integration/lib/mock-ase.ts b/test/integration/lib/mock-ase.ts index 4b9d7f81c9..31a40a8e4c 100644 --- a/test/integration/lib/mock-ase.ts +++ b/test/integration/lib/mock-ase.ts @@ -33,7 +33,8 @@ export class MockASE { this.apolloClient = createApolloClient({ graphqlUrl: config.graphqlUrl, signatureSecret: config.signatureSecret, - signatureVersion: config.signatureVersion + signatureVersion: config.signatureVersion, + operatorTenantId: config.operatorTenantId }) this.adminClient = new AdminClient(this.apolloClient) this.accounts = new AccountProvider() diff --git a/test/integration/testenv/cloud-nine-wallet/.env b/test/integration/testenv/cloud-nine-wallet/.env index bf7a0b7fd0..23aa3996c0 100644 --- a/test/integration/testenv/cloud-nine-wallet/.env +++ b/test/integration/testenv/cloud-nine-wallet/.env @@ -7,5 +7,6 @@ GRAPHQL_URL=http://cloud-nine-wallet-test-backend:3101/graphql IDP_SECRET=2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= SIGNATURE_VERSION=1 SIGNATURE_SECRET=iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= +OPERATOR_TENANT_ID=438fa74a-fa7d-4317-9ced-dde32ece1787 # matches pfry key id KEY_ID=keyid-97a3a431-8ee1-48fc-ac85-70e2f5eba8e5 \ No newline at end of file diff --git a/test/integration/testenv/happy-life-bank/.env b/test/integration/testenv/happy-life-bank/.env index 9ab63a2ed7..4037251585 100644 --- a/test/integration/testenv/happy-life-bank/.env +++ b/test/integration/testenv/happy-life-bank/.env @@ -7,5 +7,6 @@ GRAPHQL_URL=http://happy-life-bank-test-backend:4101/graphql IDP_SECRET=2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= SIGNATURE_VERSION=1 SIGNATURE_SECRET=iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= +OPERATOR_TENANT_ID=cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d # matches pfry key id KEY_ID=keyid-97a3a431-8ee1-48fc-ac85-70e2f5eba8e5 \ No newline at end of file