diff --git a/packages/auth/src/accessToken/service.test.ts b/packages/auth/src/accessToken/service.test.ts index 3ef9d4f94c..5ae96a4514 100644 --- a/packages/auth/src/accessToken/service.test.ts +++ b/packages/auth/src/accessToken/service.test.ts @@ -122,6 +122,34 @@ describe('Access Token Service', (): void => { }) }) + describe('Get', (): void => { + let accessToken: AccessToken + beforeEach(async (): Promise => { + accessToken = await AccessToken.query(trx).insert({ + value: 'test-access-token', + managementId: v4(), + grantId: grant.id, + expiresIn: 1234 + }) + }) + + test('Can get an access token by its value', async (): Promise => { + const fetchedToken = await accessTokenService.get(accessToken.value) + expect(fetchedToken.value).toEqual(accessToken.value) + expect(fetchedToken.managementId).toEqual(accessToken.managementId) + expect(fetchedToken.grantId).toEqual(accessToken.grantId) + }) + + test('Can get an access token by its managementId', async (): Promise => { + const fetchedToken = await accessTokenService.getByManagementId( + accessToken.managementId + ) + expect(fetchedToken.value).toEqual(accessToken.value) + expect(fetchedToken.managementId).toEqual(accessToken.managementId) + expect(fetchedToken.grantId).toEqual(accessToken.grantId) + }) + }) + describe('Introspect', (): void => { test('Can introspect active token', async (): Promise => { const clientId = crypto diff --git a/packages/auth/src/accessToken/service.ts b/packages/auth/src/accessToken/service.ts index f0f79631ae..c307f0bbf7 100644 --- a/packages/auth/src/accessToken/service.ts +++ b/packages/auth/src/accessToken/service.ts @@ -10,6 +10,8 @@ import { IAppConfig } from '../config/app' import { Access } from '../access/model' export interface AccessTokenService { + get(token: string): Promise + getByManagementId(managementId: string): Promise introspect(token: string): Promise revoke(id: string): Promise create(grantId: string, opts?: AccessTokenOpts): Promise @@ -64,6 +66,9 @@ export async function createAccessTokenService({ } return { + get: (token: string) => get(token), + getByManagementId: (managementId: string) => + getByManagementId(managementId), introspect: (token: string) => introspect(deps, token), revoke: (id: string) => revoke(deps, id), create: (grantId: string, opts?: AccessTokenOpts) => @@ -78,6 +83,14 @@ function isTokenExpired(token: AccessToken): boolean { return expiresAt < now.getTime() } +async function get(token: string): Promise { + return AccessToken.query().findOne('value', token) +} + +async function getByManagementId(managementId: string): Promise { + return AccessToken.query().findOne('managementId', managementId) +} + async function introspect( deps: ServiceDependencies, value: string diff --git a/packages/auth/src/app.ts b/packages/auth/src/app.ts index 1aaf332a29..9e4b67ff67 100644 --- a/packages/auth/src/app.ts +++ b/packages/auth/src/app.ts @@ -185,7 +185,7 @@ export class App { const accessTokenRoutes = await this.container.use('accessTokenRoutes') const grantRoutes = await this.container.use('grantRoutes') - const clientService = await this.container.use('clientService') + const signatureService = await this.container.use('signatureService') const openApi = await this.container.use('openApi') const toRouterPath = (path: string): string => @@ -225,7 +225,7 @@ export class App { path, method }), - clientService.tokenHttpsigMiddleware, + signatureService.tokenHttpsigMiddleware, route ) // TODO: remove once all endpoints are implemented diff --git a/packages/auth/src/client/service.test.ts b/packages/auth/src/client/service.test.ts index ac4f01b61f..477c66149a 100644 --- a/packages/auth/src/client/service.test.ts +++ b/packages/auth/src/client/service.test.ts @@ -1,23 +1,13 @@ -import crypto from 'crypto' import nock from 'nock' -import { importJWK } from 'jose' -import { v4 } from 'uuid' -import { Knex } from 'knex' import { createTestApp, TestContainer } from '../tests/app' -import { truncateTables } from '../tests/tableManager' import { Config } from '../config/app' import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../' import { AppServices } from '../app' import { v4 } from 'uuid' import { ClientService, JWKWithRequired } from './service' -import { createContext, createContextWithSigHeaders } from '../tests/context' import { generateTestKeys } from '../tests/signature' -import { Grant, GrantState, StartMethod, FinishMethod } from '../grant/model' -import { Access } from '../access/model' -import { AccessToken } from '../accessToken/model' -import { AccessType, Action } from '../access/types' import { KID_ORIGIN } from '../grant/routes.test' const TEST_CLIENT_DISPLAY = { @@ -33,11 +23,6 @@ describe('Client Service', (): void => { let clientService: ClientService let keyPath: string let publicKey: JWKWithRequired - let privateKey: JWKWithRequired - let testClientKey: { - proof: string - jwk: JWKWithRequired - } beforeAll(async (): Promise => { deps = await initIocContainer(Config) @@ -59,333 +44,6 @@ describe('Client Service', (): void => { await appContainer.shutdown() }) - describe('signatures', (): void => { - test('can verify a signature', async (): Promise => { - const challenge = 'test-challenge' - const privateJwk = (await importJWK(privateKey)) as crypto.KeyLike - const signature = crypto.sign(null, Buffer.from(challenge), privateJwk) - await expect( - clientService.verifySig( - signature.toString('base64'), - publicKey, - challenge - ) - ).resolves.toBe(true) - }) - - test('can construct a challenge from signature input', (): void => { - const sigInputHeader = - 'sig1=("@method" "@target-uri" "content-digest" "content-length" "content-type" "authorization");created=1618884473;keyid="gnap-key"' - const ctx = createContext( - { - headers: { - 'Content-Type': 'application/json', - 'Content-Digest': 'sha-256=:test-hash:', - 'Content-Length': '1234', - 'Signature-Input': sigInputHeader, - Authorization: 'GNAP test-access-token' - }, - method: 'GET', - url: '/test' - }, - {} - ) - - ctx.request.body = { foo: 'bar' } - - const challenge = clientService.sigInputToChallenge(sigInputHeader, ctx) - expect(challenge).toEqual( - `"@method": GET\n"@target-uri": /test\n"content-digest": sha-256=:test-hash:\n"content-length": 1234\n"content-type": application/json\n"authorization": GNAP test-access-token\n"@signature-params": ${sigInputHeader.replace( - 'sig1=', - '' - )}` - ) - }) - - test.each` - title | sigInputHeader - ${'fails if a component is not in lower case'} | ${'sig1=("@METHOD" "@target-uri" "content-digest" "content-length" "content-type" "authorization");created=1618884473;keyid="gnap-key"'} - ${'fails @method is missing'} | ${'sig1=("@target-uri" "content-digest" "content-length" "content-type");created=1618884473;keyid="gnap-key"'} - ${'fails if @target-uri is missing'} | ${'sig1=("@method" "content-digest" "content-length" "content-type");created=1618884473;keyid="gnap-key"'} - ${'fails if @content-digest is missing while body is present'} | ${'sig1=("@method" "@target-uri" "content-length" "content-type");created=1618884473;keyid="gnap-key"'} - ${'fails if authorization header is present in headers but not in signature input'} | ${'sig1=("@method" "@target-uri" "content-digest" "content-length" "content-type");created=1618884473;keyid="gnap-key"'} - `( - 'constructs signature input and $title', - async ({ sigInputHeader }): Promise => { - const ctx = createContext( - { - headers: { - 'Content-Type': 'application/json', - 'Content-Digest': 'sha-256=:test-hash:', - 'Content-Length': '1234', - 'Signature-Input': sigInputHeader, - Authorization: 'GNAP test-access-token' - }, - method: 'GET', - url: '/test' - }, - {} - ) - - ctx.request.body = { foo: 'bar' } - ctx.method = 'GET' - ctx.request.url = '/test' - - expect(clientService.sigInputToChallenge(sigInputHeader, ctx)).toBe( - null - ) - } - ) - }) - - describe('Signature middleware', (): void => { - let grant: Grant - let token: AccessToken - let knex: Knex - let trx: Knex.Transaction - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let next: () => Promise - let managementId: string - let tokenManagementUrl: string - - const BASE_GRANT = { - state: GrantState.Pending, - startMethod: [StartMethod.Redirect], - continueToken: crypto.randomBytes(8).toString('hex').toUpperCase(), - continueId: v4(), - finishMethod: FinishMethod.Redirect, - finishUri: 'https://example.com/finish', - clientNonce: crypto.randomBytes(8).toString('hex').toUpperCase(), - interactId: v4(), - interactRef: crypto.randomBytes(8).toString('hex').toUpperCase(), - interactNonce: crypto.randomBytes(8).toString('hex').toUpperCase() - } - - const BASE_ACCESS = { - type: AccessType.OutgoingPayment, - actions: [Action.Read, Action.Create], - limits: { - receivingAccount: 'https://wallet.com/alice', - sendAmount: { - value: '400', - assetCode: 'USD', - assetScale: 2 - } - } - } - - const BASE_TOKEN = { - value: crypto.randomBytes(8).toString('hex').toUpperCase(), - managementId: v4(), - expiresIn: 3600 - } - - beforeAll(async (): Promise => { - knex = await deps.use('knex') - }) - - beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch({ - ...BASE_GRANT, - clientKeyId: KID_ORIGIN + keyPath - }) - await Access.query(trx).insertAndFetch({ - grantId: grant.id, - ...BASE_ACCESS - }) - token = await AccessToken.query(trx).insertAndFetch({ - grantId: grant.id, - ...BASE_TOKEN - }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - next = jest.fn(async function (): Promise { - return null - }) - - managementId = token.managementId - tokenManagementUrl = `/token/${managementId}` - }) - - afterEach(async (): Promise => { - jest.useRealTimers() - await truncateTables(knex) - }) - - test('Validate POST / request with middleware', async (): Promise => { - const scope = nock(KID_ORIGIN) - .get(keyPath) - .reply(200, { - keys: [testClientKey.jwk], - ...TEST_CLIENT_DISPLAY - }) - - const ctx = await createContextWithSigHeaders( - { - headers: { - Accept: 'application/json' - }, - url: '/', - method: 'POST' - }, - {}, - { - client: { - display: TEST_CLIENT_DISPLAY, - key: testClientKey - } - }, - privateKey - ) - - await clientService.tokenHttpsigMiddleware(ctx, next) - - expect(ctx.response.status).toEqual(200) - expect(next).toHaveBeenCalled() - - scope.isDone() - }) - - test('Validate /introspect request with middleware', async (): Promise => { - const scope = nock(KID_ORIGIN) - .get(keyPath) - .reply(200, { - keys: [testClientKey.jwk] - }) - - const ctx = await createContextWithSigHeaders( - { - headers: { - Accept: 'application/json' - }, - url: '/introspect', - method: 'POST' - }, - {}, - { - access_token: token.value, - proof: 'httpsig', - resource_server: 'test' - }, - privateKey - ) - - await clientService.tokenHttpsigMiddleware(ctx, next) - - expect(next).toHaveBeenCalled() - expect(ctx.response.status).toEqual(200) - - scope.isDone() - }) - - test('Validate DEL /token request with middleware', async () => { - const scope = nock(KID_ORIGIN) - .get(keyPath) - .reply(200, { - keys: [testClientKey.jwk] - }) - - const ctx = await createContextWithSigHeaders( - { - headers: { - Accept: 'application/json' - }, - url: tokenManagementUrl, - method: 'DELETE' - }, - { managementId }, - { - access_token: token.value, - proof: 'httpsig', - resource_server: 'test' - }, - privateKey - ) - - await clientService.tokenHttpsigMiddleware(ctx, next) - - expect(next).toHaveBeenCalled() - expect(ctx.response.status).toEqual(200) - - scope.isDone() - }) - - test('httpsig middleware fails if client is invalid', async () => { - const grant = await Grant.query(trx).insertAndFetch({ - ...BASE_GRANT, - continueToken: crypto.randomBytes(8).toString('hex'), - continueId: v4(), - interactId: v4(), - interactNonce: crypto.randomBytes(8).toString('hex'), - interactRef: v4(), - clientKeyId: 'https://openpayments.network/wrong-key' - }) - await Access.query(trx).insertAndFetch({ - grantId: grant.id, - ...BASE_ACCESS - }) - const token = await AccessToken.query(trx).insertAndFetch({ - grantId: grant.id, - ...BASE_TOKEN, - value: crypto.randomBytes(8).toString('hex'), - managementId: v4() - }) - const ctx = await createContextWithSigHeaders( - { - headers: { - Accept: 'application/json' - }, - url: '/introspect', - method: 'POST' - }, - { managementId }, - { - access_token: token.value, - proof: 'httpsig', - resource_server: 'test', - test: 'middleware fail' - }, - privateKey - ) - - await clientService.tokenHttpsigMiddleware(ctx, next) - - expect(next).toHaveBeenCalled() - expect(ctx.response.status).toEqual(401) - }) - - test('httpsig middleware fails if headers are invalid', async () => { - const scope = nock(KID_ORIGIN) - .get(keyPath) - .reply(200, { - keys: [testClientKey.jwk] - }) - const method = 'DELETE' - - const ctx = createContext( - { - headers: { - Accept: 'application/json' - }, - url: tokenManagementUrl, - method - }, - { managementId } - ) - - ctx.request.body = { - access_token: token.value, - proof: 'httpsig', - resource_server: 'test' - } - await clientService.tokenHttpsigMiddleware(ctx, next) - - expect(next).toHaveBeenCalled() - expect(ctx.response.status).toEqual(400) - - scope.isDone() - }) - }) - describe('Registry Validation', (): void => { const expDate = new Date() expDate.setTime(expDate.getTime() + 1000 * 60 * 60) @@ -395,7 +53,7 @@ describe('Client Service', (): void => { describe('Client Properties', (): void => { test('Can validate client properties with registry', async (): Promise => { const scope = nock(KID_ORIGIN) - .get('/keys/correct') + .get(keyPath) .reply(200, { ...publicKey, kid: KEY_REGISTRY_ORIGIN + '/keys/correct', @@ -410,7 +68,7 @@ describe('Client Service', (): void => { proof: 'httpsig', jwk: { ...publicKey, - kid: KID_ORIGIN + '/keys/correct' + kid: KID_ORIGIN + keyPath } } }) diff --git a/packages/auth/src/client/service.ts b/packages/auth/src/client/service.ts index 76ab9b92e3..274032e469 100644 --- a/packages/auth/src/client/service.ts +++ b/packages/auth/src/client/service.ts @@ -1,14 +1,9 @@ -import * as crypto from 'crypto' import Axios from 'axios' -import { importJWK, JWK } from 'jose' +import { JWK } from 'jose' import { URL } from 'url' -import { HttpMethod } from 'openapi' import { BaseService } from '../shared/baseService' import { IAppConfig } from '../config/app' -import { AppContext } from '../app' -import { AccessToken } from '../accessToken/model' -import { Grant } from '../grant/model' export interface JWKWithRequired extends JWK { // client is the custom field representing a client in the backend @@ -50,35 +45,9 @@ interface ServiceDependencies extends BaseService { config: IAppConfig } -interface VerifySigResult { - success: boolean - status?: number - error?: string - message?: string -} - export interface ClientService { - verifySig( - sig: string, - jwk: JWKWithRequired, - challenge: string - ): Promise validateClient(clientInfo: ClientInfo): Promise getKeyByKid(kid: string): Promise - verifySigFromBoundKey( - sig: string, - sigInput: string, - accessTokenKey: string, - accessTokenValue: string, - ctx: AppContext - ): Promise - sigInputToChallenge(sigInput: string, ctx: AppContext): string - getRegistryDataByKid(kid: string): Promise - tokenHttpsigMiddleware( - ctx: AppContext, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - next: () => Promise - ): Promise } export async function createClientService({ @@ -95,26 +64,9 @@ export async function createClientService({ } return { - verifySig: (sig: string, jwk: JWKWithRequired, challenge: string) => - verifySig(deps, sig, jwk, challenge), validateClient: (clientInfo: ClientInfo) => validateClient(deps, clientInfo), getKeyByKid: (kid: string) => getKeyByKid(deps, kid), - verifySigFromBoundKey: (sig: string, sigInput: string, grant: Grant, ctx: AppContext) => - verifySigFromBoundKey( - deps, - sig, - sigInput, - grant - ctx - ), - validateClientWithRegistry: (clientInfo: ClientInfo) => - validateClientWithRegistry(deps, clientInfo), - sigInputToChallenge: (sigInput: string, ctx: AppContext) => - sigInputToChallenge(sigInput, ctx), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tokenHttpsigMiddleware: (ctx: AppContext, next: () => Promise) => - tokenHttpsigMiddleware(deps, ctx, next) } } @@ -310,115 +262,3 @@ function verifyJwk(jwk: JWKWithRequired, keys: RegistryKey): boolean { // TODO: update this to reflect eventual shape of response from registry return !!(!keys.revoked && isJwkViable(keys) && keys.x === jwk.x) } - -async function tokenHttpsigMiddleware( - deps: ServiceDependencies, - ctx: AppContext, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - next: () => Promise -): Promise { - const sig = ctx.headers['signature'] - const sigInput = ctx.headers['signature-input'] - - if ( - !sig || - !sigInput || - typeof sig !== 'string' || - typeof sigInput !== 'string' - ) { - ctx.status = 400 - ctx.body = { - error: 'invalid_request', - message: 'invalid signature headers' - } - next() - return - } - - const { body } = ctx.request - const { path, method } = ctx - let verified: VerifySigResult - if ( - path.includes('/introspect') && - method === HttpMethod.POST.toUpperCase() - ) { - const accessToken = await AccessToken.query().findOne( - 'value', - body['access_token'] - ) - if (!accessToken) { - ctx.status = 401 - ctx.body = { - error: 'invalid_client', - message: 'invalid access token' - } - - return - } - - const grant = await Grant.query().findById(accessToken.grantId) - verified = await verifySigFromBoundKey(deps, sig, sigInput, grant, ctx) - } else if ( - path.includes('/token') && - method === HttpMethod.DELETE.toUpperCase() - ) { - const accessToken = await AccessToken.query().findOne( - 'managementId', - ctx.params['managementId'] - ) - if (!accessToken) { - ctx.status = 401 - ctx.body = { - error: 'invalid_client', - message: 'invalid access token' - } - return - } - - const grant = await Grant.query().findById(accessToken.grantId) - verified = await verifySigFromBoundKey(deps, sig, sigInput, grant, ctx) - } else if (path.includes('/continue')) { - const grant = await Grant.query().findOne( - 'interactId', - ctx.params['interactId'] - ) - if (!grant) { - ctx.status = 401 - ctx.body = { - error: 'invalid_interaction', - message: 'invalid grant' - } - return - } - verified = await verifySigFromBoundKey(deps, sig, sigInput, grant, ctx) - } else if (path === '/' && method === HttpMethod.POST.toUpperCase()) { - if (!(await validateClientWithRegistry(deps, body.client))) { - ctx.status = 401 - ctx.body = { error: 'invalid_client' } - return - } - verified = await verifySigAndChallenge( - deps, - sig, - sigInput, - body.client.key.jwk, - ctx - ) - } else { - // route does not need httpsig verification - next() - return - } - - if (!verified.success) { - ctx.status = verified.status || 401 - ctx.body = { - error: verified.error || 'request_denied', - message: verified.message || null - } - next() - return - } - - next() -} diff --git a/packages/auth/src/grant/service.test.ts b/packages/auth/src/grant/service.test.ts index 7a081ec1ae..a9a1d22f4a 100644 --- a/packages/auth/src/grant/service.test.ts +++ b/packages/auth/src/grant/service.test.ts @@ -178,10 +178,16 @@ describe('Grant Service', (): void => { }) }) - test('Can fetch a grant by its interaction information', async (): Promise => { - const fetchedGrant = await grantService.getByInteraction(grant.interactId) - expect(fetchedGrant?.id).toEqual(grant.id) - expect(fetchedGrant?.interactId).toEqual(grant.interactId) + describe('get', (): void => { + test('Can fetch a grant by id', async () => { + const fetchedGrant = await grantService.get(grant.id) + expect(fetchedGrant?.id).toEqual(grant.id) + }) + test('Can fetch a grant by its interaction information', async (): Promise => { + const fetchedGrant = await grantService.getByInteraction(grant.interactId) + expect(fetchedGrant?.id).toEqual(grant.id) + expect(fetchedGrant?.interactId).toEqual(grant.interactId) + }) }) describe('deny', (): void => { diff --git a/packages/auth/src/grant/service.ts b/packages/auth/src/grant/service.ts index 0ae581bf02..c04b17a070 100644 --- a/packages/auth/src/grant/service.ts +++ b/packages/auth/src/grant/service.ts @@ -9,6 +9,7 @@ import { ClientInfo } from '../client/service' import { AccessService } from '../access/service' export interface GrantService { + get(grantId: string): Promise initiateGrant(grantRequest: GrantRequest): Promise getByInteraction(interactId: string): Promise issueGrant(grantId: string): Promise @@ -69,6 +70,7 @@ export async function createGrantService({ knex } return { + get: (grantId: string) => get(grantId), initiateGrant: (grantRequest: GrantRequest, trx?: Transaction) => initiateGrant(deps, grantRequest, trx), getByInteraction: (interactId: string) => getByInteraction(interactId), @@ -82,6 +84,10 @@ export async function createGrantService({ } } +async function get(grantId: string): Promise { + return Grant.query().findById(grantId) +} + async function issueGrant( deps: ServiceDependencies, grantId: string diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 471ddec598..d0c0258d07 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -13,6 +13,7 @@ import { createAccessTokenService } from './accessToken/service' import { createAccessTokenRoutes } from './accessToken/routes' import { createGrantRoutes } from './grant/routes' import { createOpenAPI } from 'openapi' +import { createSignatureService } from './signature/service' export { JWKWithRequired } from './client/service' const container = initIocContainer(Config) @@ -130,6 +131,19 @@ export function initIocContainer( } ) + container.singleton( + 'signatureService', + async (deps: IocContract) => { + return createSignatureService({ + config: await deps.use('config'), + logger: await deps.use('logger'), + clientService: await deps.use('clientService'), + grantService: await deps.use('grantService'), + accessTokenService: await deps.use('accessTokenService') + }) + } + ) + return container } diff --git a/packages/auth/src/signature/service.test.ts b/packages/auth/src/signature/service.test.ts new file mode 100644 index 0000000000..7f76b7606e --- /dev/null +++ b/packages/auth/src/signature/service.test.ts @@ -0,0 +1,389 @@ +import crypto from 'crypto' +import nock from 'nock' +import { importJWK } from 'jose' +import { v4 } from 'uuid' +import { Knex } from 'knex' + +import { createTestApp, TestContainer } from '../tests/app' +import { truncateTables } from '../tests/tableManager' +import { Config } from '../config/app' +import { IocContract } from '@adonisjs/fold' +import { initIocContainer } from '../' +import { AppServices } from '../app' +import { SignatureService } from './service' +import { JWKWithRequired } from '../client/service' +import { createContext, createContextWithSigHeaders } from '../tests/context' +import { generateTestKeys } from '../tests/signature' +import { Grant, GrantState, StartMethod, FinishMethod } from '../grant/model' +import { Access } from '../access/model' +import { AccessToken } from '../accessToken/model' +import { AccessType, Action } from '../access/types' +import { KID_ORIGIN } from '../grant/routes.test' + +const TEST_CLIENT_DISPLAY = { + name: 'Test Client', + url: 'https://example.com' +} + +describe('Signature Service', (): void => { + let deps: IocContract + let appContainer: TestContainer + let signatureService: SignatureService + let keyPath: string + let publicKey: JWKWithRequired + let privateKey: JWKWithRequired + let testClientKey: { + proof: string + jwk: JWKWithRequired + } + + beforeAll(async (): Promise => { + deps = await initIocContainer(Config) + signatureService = await deps.use('signatureService') + appContainer = await createTestApp(deps) + + const keys = await generateTestKeys() + keyPath = '/' + keys.keyId + publicKey = keys.publicKey + privateKey = keys.privateKey + testClientKey = { + proof: 'httpsig', + jwk: publicKey + } + }) + + afterAll(async (): Promise => { + nock.restore() + await appContainer.shutdown() + }) + + describe('signatures', (): void => { + test('can verify a signature', async (): Promise => { + const challenge = 'test-challenge' + const privateJwk = (await importJWK(privateKey)) as crypto.KeyLike + const signature = crypto.sign(null, Buffer.from(challenge), privateJwk) + await expect( + signatureService.verifySig( + signature.toString('base64'), + publicKey, + challenge + ) + ).resolves.toBe(true) + }) + + test('can construct a challenge from signature input', (): void => { + const sigInputHeader = + 'sig1=("@method" "@target-uri" "content-digest" "content-length" "content-type" "authorization");created=1618884473;keyid="gnap-key"' + const ctx = createContext( + { + headers: { + 'Content-Type': 'application/json', + 'Content-Digest': 'sha-256=:test-hash:', + 'Content-Length': '1234', + 'Signature-Input': sigInputHeader, + Authorization: 'GNAP test-access-token' + }, + method: 'GET', + url: '/test' + }, + {} + ) + + ctx.request.body = { foo: 'bar' } + + const challenge = signatureService.sigInputToChallenge( + sigInputHeader, + ctx + ) + expect(challenge).toEqual( + `"@method": GET\n"@target-uri": /test\n"content-digest": sha-256=:test-hash:\n"content-length": 1234\n"content-type": application/json\n"authorization": GNAP test-access-token\n"@signature-params": ${sigInputHeader.replace( + 'sig1=', + '' + )}` + ) + }) + + test.each` + title | sigInputHeader + ${'fails if a component is not in lower case'} | ${'sig1=("@METHOD" "@target-uri" "content-digest" "content-length" "content-type" "authorization");created=1618884473;keyid="gnap-key"'} + ${'fails @method is missing'} | ${'sig1=("@target-uri" "content-digest" "content-length" "content-type");created=1618884473;keyid="gnap-key"'} + ${'fails if @target-uri is missing'} | ${'sig1=("@method" "content-digest" "content-length" "content-type");created=1618884473;keyid="gnap-key"'} + ${'fails if @content-digest is missing while body is present'} | ${'sig1=("@method" "@target-uri" "content-length" "content-type");created=1618884473;keyid="gnap-key"'} + ${'fails if authorization header is present in headers but not in signature input'} | ${'sig1=("@method" "@target-uri" "content-digest" "content-length" "content-type");created=1618884473;keyid="gnap-key"'} + `( + 'constructs signature input and $title', + async ({ sigInputHeader }): Promise => { + const ctx = createContext( + { + headers: { + 'Content-Type': 'application/json', + 'Content-Digest': 'sha-256=:test-hash:', + 'Content-Length': '1234', + 'Signature-Input': sigInputHeader, + Authorization: 'GNAP test-access-token' + }, + method: 'GET', + url: '/test' + }, + {} + ) + + ctx.request.body = { foo: 'bar' } + ctx.method = 'GET' + ctx.request.url = '/test' + + expect(signatureService.sigInputToChallenge(sigInputHeader, ctx)).toBe( + null + ) + } + ) + }) + + describe('Signature middleware', (): void => { + let grant: Grant + let token: AccessToken + let knex: Knex + let trx: Knex.Transaction + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let next: () => Promise + let managementId: string + let tokenManagementUrl: string + + const BASE_GRANT = { + state: GrantState.Pending, + startMethod: [StartMethod.Redirect], + continueToken: crypto.randomBytes(8).toString('hex').toUpperCase(), + continueId: v4(), + finishMethod: FinishMethod.Redirect, + finishUri: 'https://example.com/finish', + clientNonce: crypto.randomBytes(8).toString('hex').toUpperCase(), + interactId: v4(), + interactRef: crypto.randomBytes(8).toString('hex').toUpperCase(), + interactNonce: crypto.randomBytes(8).toString('hex').toUpperCase() + } + + const BASE_ACCESS = { + type: AccessType.OutgoingPayment, + actions: [Action.Read, Action.Create], + limits: { + receivingAccount: 'https://wallet.com/alice', + sendAmount: { + value: '400', + assetCode: 'USD', + assetScale: 2 + } + } + } + + const BASE_TOKEN = { + value: crypto.randomBytes(8).toString('hex').toUpperCase(), + managementId: v4(), + expiresIn: 3600 + } + + beforeAll(async (): Promise => { + knex = await deps.use('knex') + }) + + beforeEach(async (): Promise => { + grant = await Grant.query(trx).insertAndFetch({ + ...BASE_GRANT, + clientKeyId: KID_ORIGIN + keyPath + }) + await Access.query(trx).insertAndFetch({ + grantId: grant.id, + ...BASE_ACCESS + }) + token = await AccessToken.query(trx).insertAndFetch({ + grantId: grant.id, + ...BASE_TOKEN + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + next = jest.fn(async function (): Promise { + return null + }) + + managementId = token.managementId + tokenManagementUrl = `/token/${managementId}` + }) + + afterEach(async (): Promise => { + jest.useRealTimers() + await truncateTables(knex) + }) + + test('Validate POST / request with middleware', async (): Promise => { + const scope = nock(KID_ORIGIN) + .get(keyPath) + .reply(200, { + keys: [testClientKey.jwk], + ...TEST_CLIENT_DISPLAY + }) + + const ctx = await createContextWithSigHeaders( + { + headers: { + Accept: 'application/json' + }, + url: '/', + method: 'POST' + }, + {}, + { + client: { + display: TEST_CLIENT_DISPLAY, + key: testClientKey + } + }, + privateKey + ) + + await signatureService.tokenHttpsigMiddleware(ctx, next) + + expect(ctx.response.status).toEqual(200) + expect(next).toHaveBeenCalled() + + scope.isDone() + }) + + test('Validate /introspect request with middleware', async (): Promise => { + const scope = nock(KID_ORIGIN) + .get(keyPath) + .reply(200, { + keys: [testClientKey.jwk] + }) + + const ctx = await createContextWithSigHeaders( + { + headers: { + Accept: 'application/json' + }, + url: '/introspect', + method: 'POST' + }, + {}, + { + access_token: token.value, + proof: 'httpsig', + resource_server: 'test' + }, + privateKey + ) + + await signatureService.tokenHttpsigMiddleware(ctx, next) + + expect(next).toHaveBeenCalled() + expect(ctx.response.status).toEqual(200) + + scope.isDone() + }) + + test('Validate DEL /token request with middleware', async () => { + const scope = nock(KID_ORIGIN) + .get(keyPath) + .reply(200, { + keys: [testClientKey.jwk] + }) + + const ctx = await createContextWithSigHeaders( + { + headers: { + Accept: 'application/json' + }, + url: tokenManagementUrl, + method: 'DELETE' + }, + { managementId }, + { + access_token: token.value, + proof: 'httpsig', + resource_server: 'test' + }, + privateKey + ) + + await signatureService.tokenHttpsigMiddleware(ctx, next) + + expect(next).toHaveBeenCalled() + expect(ctx.response.status).toEqual(200) + + scope.isDone() + }) + + test('httpsig middleware fails if client is invalid', async () => { + const grant = await Grant.query(trx).insertAndFetch({ + ...BASE_GRANT, + continueToken: crypto.randomBytes(8).toString('hex'), + continueId: v4(), + interactId: v4(), + interactNonce: crypto.randomBytes(8).toString('hex'), + interactRef: v4(), + clientKeyId: 'https://openpayments.network/wrong-key' + }) + await Access.query(trx).insertAndFetch({ + grantId: grant.id, + ...BASE_ACCESS + }) + const token = await AccessToken.query(trx).insertAndFetch({ + grantId: grant.id, + ...BASE_TOKEN, + value: crypto.randomBytes(8).toString('hex'), + managementId: v4() + }) + const ctx = await createContextWithSigHeaders( + { + headers: { + Accept: 'application/json' + }, + url: '/introspect', + method: 'POST' + }, + { managementId }, + { + access_token: token.value, + proof: 'httpsig', + resource_server: 'test', + test: 'middleware fail' + }, + privateKey + ) + + await signatureService.tokenHttpsigMiddleware(ctx, next) + + expect(next).toHaveBeenCalled() + expect(ctx.response.status).toEqual(401) + }) + + test('httpsig middleware fails if headers are invalid', async () => { + const scope = nock(KID_ORIGIN) + .get(keyPath) + .reply(200, { + keys: [testClientKey.jwk] + }) + const method = 'DELETE' + + const ctx = createContext( + { + headers: { + Accept: 'application/json' + }, + url: tokenManagementUrl, + method + }, + { managementId } + ) + + ctx.request.body = { + access_token: token.value, + proof: 'httpsig', + resource_server: 'test' + } + await signatureService.tokenHttpsigMiddleware(ctx, next) + + expect(next).toHaveBeenCalled() + expect(ctx.response.status).toEqual(400) + + scope.isDone() + }) + }) +}) diff --git a/packages/auth/src/signature/service.ts b/packages/auth/src/signature/service.ts new file mode 100644 index 0000000000..c21e9df616 --- /dev/null +++ b/packages/auth/src/signature/service.ts @@ -0,0 +1,277 @@ +import * as crypto from 'crypto' +import { importJWK } from 'jose' +import { HttpMethod } from 'openapi' + +import { AppContext } from '../app' +import { BaseService } from '../shared/baseService' +import { IAppConfig } from '../config/app' +import { Grant } from '../grant/model' +import { GrantService } from '../grant/service' +import { AccessTokenService } from '../accessToken/service' +import { ClientService, JWKWithRequired } from '../client/service' + +interface ServiceDependencies extends BaseService { + config: IAppConfig + grantService: GrantService + accessTokenService: AccessTokenService + clientService: ClientService +} + +interface VerifySigResult { + success: boolean + status?: number + error?: string + message?: string +} + +export interface SignatureService { + verifySig( + sig: string, + jwk: JWKWithRequired, + challenge: string + ): Promise + sigInputToChallenge(sigInput: string, ctx: AppContext): string | null + tokenHttpsigMiddleware( + ctx: AppContext, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + next: () => Promise + ): Promise +} + +export async function createSignatureService({ + logger, + config, + grantService, + accessTokenService, + clientService +}: ServiceDependencies): Promise { + const log = logger.child({ + service: 'SignatureService' + }) + + const deps: ServiceDependencies = { + logger: log, + config, + grantService, + accessTokenService, + clientService + } + + return { + verifySig: (sig: string, jwk: JWKWithRequired, challenge: string) => + verifySig(deps, sig, jwk, challenge), + sigInputToChallenge: (sigInput: string, ctx: AppContext) => + sigInputToChallenge(sigInput, ctx), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tokenHttpsigMiddleware: (ctx: AppContext, next: () => Promise) => + tokenHttpsigMiddleware(deps, ctx, next) + } +} + +async function verifySig( + deps: ServiceDependencies, + sig: string, + jwk: JWKWithRequired, + challenge: string +): Promise { + const publicKey = (await importJWK(jwk)) as crypto.KeyLike + const data = Buffer.from(challenge) + return crypto.verify(null, data, publicKey, Buffer.from(sig, 'base64')) +} + +async function verifySigAndChallenge( + deps: ServiceDependencies, + sig: string, + sigInput: string, + clientKey: JWKWithRequired, + ctx: AppContext +): Promise { + const challenge = sigInputToChallenge(sigInput, ctx) + if (!challenge) { + return { + success: false, + status: 400, + error: 'invalid_request', + message: 'invalid Sig-Input' + } + } + + return { + success: await verifySig( + deps, + sig.replace('sig1=', ''), + clientKey, + challenge + ) + } +} + +async function verifySigFromBoundKey( + deps: ServiceDependencies, + sig: string, + sigInput: string, + grant: Grant, + ctx: AppContext +): Promise { + const registryData = await deps.clientService.getRegistryDataByKid( + grant.clientKeyId + ) + if (!registryData) + return { + success: false, + error: 'invalid_client', + status: 401 + } + const { keys } = registryData + const clientKey = keys[0] + + return verifySigAndChallenge(deps, sig, sigInput, clientKey, ctx) +} + +function sigInputToChallenge(sigInput: string, ctx: AppContext): string | null { + // https://datatracker.ietf.org/doc/html/rfc8941#section-4.1.1.1 + const messageComponents = sigInput.split('sig1=')[1].split(';')[0].split(' ') + const cleanMessageComponents = messageComponents.map((component) => + component.replace(/[()"]/g, '') + ) + + // https://datatracker.ietf.org/doc/html/draft-ietf-gnap-core-protocol#section-7.3.1 + if ( + !cleanMessageComponents.includes('@method') || + !cleanMessageComponents.includes('@target-uri') || + (ctx.request.body && !cleanMessageComponents.includes('content-digest')) || + (ctx.headers['authorization'] && + !cleanMessageComponents.includes('authorization')) + ) { + return null + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-09#section-2.3 + let signatureBase = '' + for (const component of cleanMessageComponents) { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-09#section-2.1 + if (component !== component.toLowerCase()) { + return null + } + + if (component === '@method') { + signatureBase += `"@method": ${ctx.request.method}\n` + } else if (component === '@target-uri') { + signatureBase += `"@target-uri": ${ctx.request.url}\n` + } else { + signatureBase += `"${component}": ${ctx.headers[component]}\n` + } + } + + signatureBase += `"@signature-params": ${( + ctx.headers['signature-input'] as string + )?.replace('sig1=', '')}` + return signatureBase +} + +async function tokenHttpsigMiddleware( + deps: ServiceDependencies, + ctx: AppContext, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + next: () => Promise +): Promise { + const sig = ctx.headers['signature'] + const sigInput = ctx.headers['signature-input'] + + if ( + !sig || + !sigInput || + typeof sig !== 'string' || + typeof sigInput !== 'string' + ) { + ctx.status = 400 + ctx.body = { + error: 'invalid_request', + message: 'invalid signature headers' + } + next() + return + } + + const { body } = ctx.request + const { path, method } = ctx + let verified: VerifySigResult + if ( + path.includes('/introspect') && + method === HttpMethod.POST.toUpperCase() + ) { + const accessToken = await deps.accessTokenService.get(body['access_token']) + if (!accessToken) { + ctx.status = 401 + ctx.body = { + error: 'invalid_client', + message: 'invalid access token' + } + + return + } + + const grant = await deps.grantService.get(accessToken.grantId) + verified = await verifySigFromBoundKey(deps, sig, sigInput, grant, ctx) + } else if ( + path.includes('/token') && + method === HttpMethod.DELETE.toUpperCase() + ) { + const accessToken = await deps.accessTokenService.getByManagementId( + ctx.params['managementId'] + ) + if (!accessToken) { + ctx.status = 401 + ctx.body = { + error: 'invalid_client', + message: 'invalid access token' + } + return + } + + const grant = await deps.grantService.get(accessToken.grantId) + verified = await verifySigFromBoundKey(deps, sig, sigInput, grant, ctx) + } else if (path.includes('/continue')) { + const grant = await deps.grantService.getByInteraction( + ctx.params['interactId'] + ) + if (!grant) { + ctx.status = 401 + ctx.body = { + error: 'invalid_interaction', + message: 'invalid grant' + } + return + } + verified = await verifySigFromBoundKey(deps, sig, sigInput, grant, ctx) + } else if (path === '/' && method === HttpMethod.POST.toUpperCase()) { + if (!(await deps.clientService.validateClientWithRegistry(body.client))) { + ctx.status = 401 + ctx.body = { error: 'invalid_client' } + return + } + verified = await verifySigAndChallenge( + deps, + sig, + sigInput, + body.client.key.jwk, + ctx + ) + } else { + // route does not need httpsig verification + next() + return + } + + if (!verified.success) { + ctx.status = verified.status || 401 + ctx.body = { + error: verified.error || 'request_denied', + message: verified.message || null + } + next() + return + } + + next() +}