Skip to content

Commit

Permalink
feat(backend): tenant signature validation for admin api
Browse files Browse the repository at this point in the history
  • Loading branch information
njlie committed Dec 6, 2024
1 parent e2bbc79 commit f4d3fb6
Show file tree
Hide file tree
Showing 5 changed files with 232 additions and 16 deletions.
28 changes: 22 additions & 6 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,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
Expand Down Expand Up @@ -144,6 +145,19 @@ export type HttpSigContext = AppContext & {
client: string
}

type TenantedHttpSigHeaders = HttpSigHeaders & Record<'tenantId', string>

type TenantedHttpSigRequest = Omit<HttpSigContext['request'], 'headers'> & {
headers: TenantedHttpSigHeaders
}

export type TenantedHttpSigContext = HttpSigContext & {
headers: TenantedHttpSigHeaders
request: TenantedHttpSigRequest
tenant?: Tenant
isOperator: boolean
}

export type HttpSigWithAuthenticatedStatusContext = HttpSigContext &
AuthenticatedStatusContext

Expand Down Expand Up @@ -384,12 +398,14 @@ export class App {
)

if (this.config.adminApiSecret) {
koa.use(async (ctx, next: Koa.Next): Promise<void> => {
if (!verifyApiSignature(ctx, this.config)) {
ctx.throw(401, 'Unauthorized')
koa.use(
async (ctx: TenantedHttpSigContext, next: Koa.Next): Promise<void> => {
if (!verifyTenantOrOperatorApiSignature(ctx, this.config)) {
ctx.throw(401, 'Unauthorized')
}
return next()
}
return next()
})
)
}

koa.use(
Expand Down
143 changes: 140 additions & 3 deletions packages/backend/src/shared/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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 => {
Expand Down Expand Up @@ -258,4 +268,131 @@ 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 config: IAppConfig
let redis: Redis

const operatorApiSecret = 'test-operator-secret'

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

afterEach(async (): Promise<void> => {
await redis.flushall()
await truncateTables(appContainer.knex)
})

afterAll(async (): Promise<void> => {
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<void> => {
const requestBody = { test: 'value' }

const signature = isOperator
? generateApiSignature(
config.adminApiSecret as string,
Config.adminApiSignatureVersion,
requestBody
)
: generateApiSignature(
tenant.apiSecret,
Config.adminApiSignatureVersion,
requestBody
)

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

Expand Down Expand Up @@ -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<boolean> {
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
Expand All @@ -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
)
}
2 changes: 1 addition & 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
3 changes: 1 addition & 2 deletions packages/backend/src/tests/tenant.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import nock from 'nock'
import { IocContract } from '@adonisjs/fold'
import { faker } from '@faker-js/faker'
import { AppServices } from '../app'
Expand All @@ -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<AppServices>,
options?: CreateOptions
Expand Down

0 comments on commit f4d3fb6

Please sign in to comment.