Skip to content

Commit

Permalink
feat(backend): tenant signature validation for admin api (#3164)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
njlie authored Dec 17, 2024
1 parent 1c43cdd commit a8b7ca4
Show file tree
Hide file tree
Showing 10 changed files with 294 additions and 16 deletions.
23 changes: 20 additions & 3 deletions packages/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -214,6 +218,11 @@ type ContextType<T> = T extends (

const WALLET_ADDRESS_PATH = '/:walletAddressPath+'

export interface TenantedApolloContext extends ApolloContext {
tenant: Tenant
isOperator: boolean
}

export interface AppServices {
logger: Promise<Logger>
telemetry: Promise<TelemetryService>
Expand Down Expand Up @@ -383,19 +392,27 @@ export class App {
}
)

let tenantApiSignatureResult: TenantApiSignatureResult
if (this.config.env !== 'test') {
koa.use(async (ctx, next: Koa.Next): Promise<void> => {
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()
})
}

koa.use(
koaMiddleware(this.apolloServer, {
context: async (): Promise<ApolloContext> => {
context: async (): Promise<TenantedApolloContext> => {
return {
...tenantApiSignatureResult,
container: this.container,
logger: await this.container.use('logger')
}
Expand Down
190 changes: 188 additions & 2 deletions packages/backend/src/shared/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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 => {
Expand Down Expand Up @@ -258,4 +270,178 @@ describe('utils', (): void => {
expect(verified).toBe(false)
})
})

describe('tenant/operator admin api signatures', (): void => {
let deps: IocContract<AppServices>
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<void> => {
deps = initIocContainer({
...Config,
adminApiSecret: operatorApiSecret
})
appContainer = await createTestApp(deps)
config = await deps.use('config')
redis = await deps.use('redis')
})

beforeEach(async (): Promise<void> => {
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<void> => {
await redis.flushall()
await truncateTables(appContainer.knex)
})

afterAll(async (): Promise<void> => {
await appContainer.shutdown()
})

test.each`
isOperator | description
${false} | ${'tenanted non-operator'}
${true} | ${'tenanted operator'}
`(
'returns if $description request has valid signature',
async ({ isOperator }): Promise<void> => {
const requestBody = { test: 'value' }

const signature = isOperator
? generateApiSignature(
operator.apiSecret,
Config.adminApiSignatureVersion,
requestBody
)
: generateApiSignature(
tenant.apiSecret,
Config.adminApiSignatureVersion,
requestBody
)

const ctx = createContext<AppContext>(
{
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<void> => {
const requestBody = { test: 'value' }
const signature = generateApiSignature(
'wrongsecret',
Config.adminApiSignatureVersion,
requestBody
)
const ctx = createContext<AppContext>(
{
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<void> => {
const requestBody = { test: 'value' }
const signature = generateApiSignature(
tenant.apiSecret,
Config.adminApiSignatureVersion,
requestBody
)
const ctx = createContext<AppContext>(
{
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<void> => {
const requestBody = { test: 'value' }
const signature = generateApiSignature(
tenant.apiSecret,
Config.adminApiSignatureVersion,
requestBody
)
const ctx = createContext<AppContext>(
{
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()
})
})
})
62 changes: 58 additions & 4 deletions packages/backend/src/shared/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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')

Expand Down Expand Up @@ -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<TenantApiSignatureResult | undefined> {
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
Expand All @@ -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
)
}
15 changes: 14 additions & 1 deletion packages/backend/src/tenants/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<void> => {
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 => {
Expand Down
Loading

0 comments on commit a8b7ca4

Please sign in to comment.