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
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
Copy link
Contributor

Choose a reason for hiding this comment

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

should we return false if we can't find the tenant?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The idea here is to account for non-tenanted requests (e.g. createTenant, listTenants, other pagination requests). But this is moot since our call where the tenant id is to be expected in any request.


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
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
config.adminApiSecret as string
config.adminApiSecret

Since it should already be typed

Copy link
Contributor

Choose a reason for hiding this comment

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

nit: but we can pass in either whole of config or adminApiSecret + adminApiSignatureVersion explicitly

)
}
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
Loading