Skip to content

Commit

Permalink
feat(auth): secure admin api with hmac signatures (#2709)
Browse files Browse the repository at this point in the history
* feat(auth): secure admin api with hmac signatures

* feat: refactor api signature script into two separate functions

* feat: rename env var, replay attack resistance

* feat(wip): test signature validation util

* feat: sanity tests for signature utility

* fix: allow explicit any

* chore: formatting

* feat: new test, context type, proper test expiry

* chore: remove log
  • Loading branch information
njlie committed Jun 11, 2024
1 parent f3093ba commit 8c601a5
Show file tree
Hide file tree
Showing 17 changed files with 330 additions and 8 deletions.
6 changes: 6 additions & 0 deletions bruno/collections/Rafiki/Rafiki Admin Auth APIs/Get Grant.bru
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,9 @@ body:graphql:vars {
"id": "97de60d6-cafa-4c8c-a847-f63ca04eb3bf"
}
}

script:pre-request {
const scripts = require('./scripts');

scripts.addApiSignatureHeader('auth');
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,9 @@ body:graphql:vars {
}

}

script:pre-request {
const scripts = require('./scripts');

scripts.addApiSignatureHeader('auth');
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,9 @@ body:graphql:vars {
}
}
}

script:pre-request {
const scripts = require('./scripts');

scripts.addApiSignatureHeader('auth');
}
6 changes: 4 additions & 2 deletions bruno/collections/Rafiki/environments/Local Playground.bru
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ vars {
happyLifeBankWebhookUrl: http://localhost:3031/webhooks
signatureUrl: https://kxu5d4mr4blcthphxomjlc4xk40rvdsx.lambda-url.eu-central-1.on.aws/
clientPrivateKey: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1DNENBUUF3QlFZREsyVndCQ0lFSUVxZXptY1BoT0U4Ymt3TitqUXJwcGZSWXpHSWRGVFZXUUdUSEpJS3B6ODgKLS0tLS1FTkQgUFJJVkFURSBLRVktLS0tLQo=
apiSignatureVersion: 1
apiSignatureSecret: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964=
assetId: USD
gfranklinWalletAddress: https://cloud-nine-wallet-backend/accounts/gfranklin
bhamchestWalletAddress: https://cloud-nine-wallet-backend/accounts/bhamchest
Expand All @@ -25,4 +23,8 @@ vars {
larsWalletAddress: https://happy-life-bank-backend/accounts/lars
davidWalletAddress: https://happy-life-bank-backend/accounts/david
withdrawalTimeout: 60
backendApiSignatureVersion: 1
backendApiSignatureSecret: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964=
authApiSignatureVersion: 1
authApiSignatureSecret: rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4=
}
36 changes: 30 additions & 6 deletions bruno/collections/Rafiki/scripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,10 @@ const scripts = {
this.setHeaders(signatureHeaders)
},

generateApiSignature: function (body) {
generateAuthApiSignature: function (body) {
const version = bru.getEnvVar('authApiSignatureVersion')
const secret = bru.getEnvVar('authApiSignatureSecret')
const timestamp = Math.round(new Date().getTime() / 1000)
const version = bru.getEnvVar('apiSignatureVersion')
const secret = bru.getEnvVar('apiSignatureSecret')
const payload = `${timestamp}.${canonicalize(body)}`
const hmac = createHmac('sha256', secret)
hmac.update(payload)
Expand All @@ -94,15 +94,39 @@ const scripts = {
return `t=${timestamp}, v${version}=${digest}`
},

addApiSignatureHeader: function () {
generateBackendApiSignature: function (body) {
const version = bru.getEnvVar('backendApiSignatureVersion')
const secret = bru.getEnvVar('backendApiSignatureSecret')
const timestamp = Math.round(new Date().getTime() / 1000)
const payload = `${timestamp}.${canonicalize(body)}`
const hmac = createHmac('sha256', secret)
hmac.update(payload)
const digest = hmac.digest('hex')

return `t=${timestamp}, v${version}=${digest}`
},

addApiSignatureHeader: function (packageName) {
const body = this.sanitizeBody()
const { variables } = body
const formattedBody = {
...body,
variables: JSON.parse(variables)
}

req.setHeader('signature', this.generateApiSignature(formattedBody))
let signature
// Default to backend api secret
switch (packageName) {
case 'backend':
signature = this.generateBackendApiSignature(formattedBody)
break
case 'auth':
signature = this.generateAuthApiSignature(formattedBody)
break
default:
signature = this.generateBackendApiSignature(formattedBody)
}
req.setHeader('signature', signature)
},

addHostHeader: function (hostVarName) {
Expand Down Expand Up @@ -150,7 +174,7 @@ const scripts = {
const postRequest = {
method: 'post',
headers: {
signature: this.generateApiSignature(postBody),
signature: this.generateBackendApiSignature(postBody),
'Content-Type': 'application/json'
},
body: JSON.stringify(postBody)
Expand Down
1 change: 1 addition & 0 deletions localenv/cloud-nine-wallet/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ services:
IDENTITY_SERVER_URL: http://localhost:3030/mock-idp/
IDENTITY_SERVER_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE=
COOKIE_KEY: 42397d1f371dd4b8b7d0308a689a57c882effd4ea909d792302542af47e2cd37
ADMIN_API_SECRET: rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4=
depends_on:
- shared-database
- shared-redis
Expand Down
1 change: 1 addition & 0 deletions localenv/happy-life-bank/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ services:
IDENTITY_SERVER_URL: http://localhost:3031/mock-idp/
IDENTITY_SERVER_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE=
COOKIE_KEY: 42397d1f371dd4b8b7d0308a689a57c882effd4ea909d792302542af47e2cd37
ADMIN_API_SECRET: rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4=
depends_on:
- cloud-nine-auth
happy-life-admin:
Expand Down
1 change: 1 addition & 0 deletions packages/auth/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const packageName = require('./package.json').name
module.exports = {
...baseConfig,
clearMocks: true,
testTimeout: 30000,
roots: [`<rootDir>/packages/${packageName}`],
globalSetup: `<rootDir>/packages/${packageName}/jest.setup.js`,
globalTeardown: `<rootDir>/packages/${packageName}/jest.teardown.js`,
Expand Down
9 changes: 9 additions & 0 deletions packages/auth/jest.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const { knex } = require('knex')
const { GenericContainer, Wait } = require('testcontainers')

const POSTGRES_PORT = 5432
const REDIS_PORT = 6379

module.exports = async (globalConfig) => {
const workers = globalConfig.maxWorkers
Expand Down Expand Up @@ -66,4 +67,12 @@ module.exports = async (globalConfig) => {
}

global.__AUTH_KNEX__ = db
if (!process.env.REDIS_URL) {
const redisContainer = await new GenericContainer('redis:7')
.withExposedPorts(REDIS_PORT)
.start()

global.__AUTH_REDIS__ = redisContainer
process.env.REDIS_URL = `redis://localhost:${redisContainer.getMappedPort(REDIS_PORT)}`
}
}
3 changes: 3 additions & 0 deletions packages/auth/jest.teardown.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@ module.exports = async () => {
if (global.__AUTH_POSTGRES__) {
await global.__AUTH_POSTGRES__.stop()
}
if (global.__AUTH_REDIS__) {
await global.__AUTH_REDIS__.stop()
}
}
1 change: 1 addition & 0 deletions packages/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"dotenv": "^16.4.5",
"graphql": "^16.8.1",
"ioredis": "^5.3.2",
"json-canonicalize": "^1.0.6",
"knex": "^3.1.0",
"koa": "^2.15.2",
"koa-bodyparser": "^4.4.1",
Expand Down
11 changes: 11 additions & 0 deletions packages/auth/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { ApolloArmor } from '@escape.tech/graphql-armor'
import { Redis } from 'ioredis'
import { LoggingPlugin } from './graphql/plugin'
import { gnapServerErrorMiddleware } from './shared/gnapErrors'
import { verifyApiSignature } from './shared/utils'

export interface AppContextData extends DefaultContext {
logger: Logger
Expand Down Expand Up @@ -214,6 +215,16 @@ export class App {
}
)

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

return next()
})
}

koa.use(
koaMiddleware(apolloServer, {
context: async (): Promise<ApolloContext> => {
Expand Down
3 changes: 3 additions & 0 deletions packages/auth/src/config/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export const Config = {
identityServerUrl: envString('IDENTITY_SERVER_URL'),
identityServerSecret: envString('IDENTITY_SERVER_SECRET'),
authServerUrl: envString('AUTH_SERVER_URL'),
adminApiSecret: process.env.ADMIN_API_SECRET, // optional
adminApiSignatureVersion: envInt('ADMIN_API_SIGNATURE_VERSION', 1),
adminApiSignatureTtl: envInt('ADMIN_API_SIGNATURE_TTL_SECONDS', 30),
waitTimeSeconds: envInt('WAIT_SECONDS', 5),
cookieKey: envString('COOKIE_KEY'),
interactionExpirySeconds: envInt('INTERACTION_EXPIRY_SECONDS', 10 * 60), // Default 10 minutes
Expand Down
148 changes: 148 additions & 0 deletions packages/auth/src/shared/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { IocContract } from '@adonisjs/fold'
import { Redis } from 'ioredis'

import { AppContext, AppServices } from '../app'
import { Config } from '../config/app'
import { createContext } from '../tests/context'
import { generateApiSignature } from '../tests/apiSignature'
import { initIocContainer } from '..'
import { verifyApiSignature } from './utils'
import { TestContainer, createTestApp } from '../tests/app'

describe('utils', (): void => {
let deps: IocContract<AppServices>
let appContainer: TestContainer
let redis: Redis

describe('admin api signatures', (): void => {
beforeAll(async (): Promise<void> => {
deps = initIocContainer({
...Config,
adminApiSecret: 'test-secret'
})
appContainer = await createTestApp(deps)
redis = await deps.use('redis')
})

afterEach(async (): Promise<void> => {
jest.useRealTimers()
await redis.flushall()
})

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

test('Can verify a signature', async (): Promise<void> => {
const requestBody = { test: 'value' }
const signature = generateApiSignature(
'test-secret',
Config.adminApiSignatureVersion,
requestBody
)
const ctx = createContext<AppContext>(
{
headers: {
Accept: 'application/json',
signature
},
url: '/graphql'
},
{},
appContainer.container
)
ctx.request.body = requestBody

const verified = await verifyApiSignature(ctx, {
...Config,
adminApiSecret: 'test-secret'
})
expect(verified).toBe(true)
})

test('verification fails if header is not present', async (): Promise<void> => {
const requestBody = { test: 'value' }
const ctx = createContext<AppContext>(
{
headers: {
Accept: 'application/json'
},
url: '/graphql'
},
{},
appContainer.container
)
ctx.request.body = requestBody

const verified = await verifyApiSignature(ctx, {
...Config,
adminApiSecret: 'test-secret'
})
expect(verified).toBe(false)
})

test('Cannot verify signature that is too old', async (): Promise<void> => {
const requestBody = { test: 'value' }
const signature = generateApiSignature(
'test-secret',
Config.adminApiSignatureVersion,
requestBody
)

const timestamp = signature.split(', ')[0].split('=')[1]
const now = new Date((Number(timestamp) + 60) * 1000)
jest.useFakeTimers({ now })
const ctx = createContext<AppContext>(
{
headers: {
Accept: 'application/json',
signature
},
url: '/graphql'
},
{},
appContainer.container
)
ctx.request.body = requestBody

const verified = await verifyApiSignature(ctx, {
...Config,
adminApiSecret: 'test-secret'
})
expect(verified).toBe(false)
})

test('Cannot verify signature that has already been processed', async (): Promise<void> => {
const requestBody = { test: 'value' }
const signature = generateApiSignature(
'test-secret',
Config.adminApiSignatureVersion,
requestBody
)
const key = `signature:${signature}`
const op = redis.multi()
op.set(key, signature)
op.expire(key, Config.adminApiSignatureTtl * 1000)
await op.exec()
const ctx = createContext<AppContext>(
{
headers: {
Accept: 'application/json',
signature
},
url: '/graphql'
},
{},
appContainer.container
)
ctx.request.body = requestBody

const verified = await verifyApiSignature(ctx, {
...Config,
adminApiSecret: 'test-secret'
})
expect(verified).toBe(false)
})
})
})
Loading

0 comments on commit 8c601a5

Please sign in to comment.