Skip to content

Commit

Permalink
Feature/nar 1555 vault setup (#178)
Browse files Browse the repository at this point in the history
* rough implementation of token/auth validation

* Test fix

* fix circular dependency on provision

* Fixing JWK/PrivateKey/PublicKey type schemas; adding tenant-specific engineJwk
  • Loading branch information
mattschoch authored Mar 25, 2024
1 parent 3a53971 commit 14060ce
Show file tree
Hide file tree
Showing 23 changed files with 652 additions and 82 deletions.
6 changes: 3 additions & 3 deletions apps/devtool/src/app/components/EditorComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import Editor from '@monaco-editor/react'
import { JWK, Payload, SigningAlg, hash, hexToBase64Url, signJwt } from '@narval/signature'
import { Jwk, Payload, SigningAlg, hash, hexToBase64Url, signJwt } from '@narval/signature'
import { getAccount, signMessage } from '@wagmi/core'
import axios from 'axios'
import Image from 'next/image'
Expand Down Expand Up @@ -46,8 +46,8 @@ const EditorComponent = () => {
const address = getAccount(config).address
if (!address) throw new Error('No address connected')

// Need real JWK
const jwk: JWK = {
// Need real Jwk
const jwk: Jwk = {
kty: 'EC',
crv: 'secp256k1',
alg: SigningAlg.ES256K,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import {
} from '@narval/policy-engine-shared'
import {
Hex,
Jwk,
Payload,
PrivateKey,
PublicKey,
SigningAlg,
base64UrlToHex,
buildSignerEip191,
Expand Down Expand Up @@ -304,8 +305,8 @@ export class OpenPolicyAgentEngine implements Engine<OpenPolicyAgentEngine> {
}

private async sign(params: { principalCredential: CredentialEntity; message: string }): Promise<JwtString> {
const engineJwk: Jwk = secp256k1PrivateKeyToJwk(this.privateKey)
const principalJwk: Jwk = params.principalCredential.key
const engineJwk: PrivateKey = secp256k1PrivateKeyToJwk(this.privateKey)
const principalJwk: PublicKey = params.principalCredential.key

const payload: Payload = {
requestHash: params.message,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ArgumentsHost, HttpStatus } from '@nestjs/common'
import { ArgumentsHost, HttpStatus, Logger } from '@nestjs/common'
import { HttpArgumentsHost } from '@nestjs/common/interfaces'
import { ConfigService } from '@nestjs/config'
import { Response } from 'express'
Expand Down Expand Up @@ -46,6 +46,8 @@ describe(ApplicationExceptionFilter.name, () => {
})

describe('catch', () => {
Logger.overrideLogger([])

describe('when environment is production', () => {
it('responds with exception status and short message', () => {
const filter = new ApplicationExceptionFilter(buildConfigServiceMock(Env.PRODUCTION))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { ArgumentsHost, HttpStatus } from '@nestjs/common'
import { HttpArgumentsHost } from '@nestjs/common/interfaces'
import { ConfigService } from '@nestjs/config'
import { Response } from 'express'
import { mock } from 'jest-mock-extended'
import { Config, Env } from '../../../../main.config'
import { ApplicationException } from '../../../../shared/exception/application.exception'
import { ApplicationExceptionFilter } from '../../../../shared/filter/application-exception.filter'

describe(ApplicationExceptionFilter.name, () => {
const exception = new ApplicationException({
message: 'Test application exception filter',
suggestedHttpStatusCode: HttpStatus.INTERNAL_SERVER_ERROR,
context: {
additional: 'information',
to: 'debug'
}
})

const buildArgumentsHostMock = (): [ArgumentsHost, jest.Mock, jest.Mock] => {
const jsonMock = jest.fn()
const statusMock = jest.fn().mockReturnValue(
mock<Response>({
json: jsonMock
})
)

const host = mock<ArgumentsHost>({
switchToHttp: jest.fn().mockReturnValue(
mock<HttpArgumentsHost>({
getResponse: jest.fn().mockReturnValue(
mock<Response>({
status: statusMock
})
)
})
)
})

return [host, statusMock, jsonMock]
}

const buildConfigServiceMock = (env: Env) =>
mock<ConfigService<Config, true>>({
get: jest.fn().mockReturnValue(env)
})

describe('catch', () => {
describe('when environment is production', () => {
it('responds with exception status and short message', () => {
const filter = new ApplicationExceptionFilter(buildConfigServiceMock(Env.PRODUCTION))
const [host, statusMock, jsonMock] = buildArgumentsHostMock()

filter.catch(exception, host)

expect(statusMock).toHaveBeenCalledWith(exception.getStatus())
expect(jsonMock).toHaveBeenCalledWith({
statusCode: exception.getStatus(),
message: exception.message,
context: exception.context
})
})
})

describe('when environment is not production', () => {
it('responds with exception status and complete message', () => {
const filter = new ApplicationExceptionFilter(buildConfigServiceMock(Env.DEVELOPMENT))
const [host, statusMock, jsonMock] = buildArgumentsHostMock()

filter.catch(exception, host)

expect(statusMock).toHaveBeenCalledWith(exception.getStatus())
expect(jsonMock).toHaveBeenCalledWith({
statusCode: exception.getStatus(),
message: exception.message,
context: exception.context,
stack: exception.stack
})
})
})
})
})
52 changes: 52 additions & 0 deletions apps/vault/src/shared/filter/application-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ArgumentsHost, Catch, ExceptionFilter, LogLevel, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { Response } from 'express'
import { Config, Env } from '../../main.config'
import { ApplicationException } from '../../shared/exception/application.exception'

@Catch(ApplicationException)
export class ApplicationExceptionFilter implements ExceptionFilter {
private logger = new Logger(ApplicationExceptionFilter.name)

constructor(private configService: ConfigService<Config, true>) {}

catch(exception: ApplicationException, host: ArgumentsHost) {
const ctx = host.switchToHttp()
const response = ctx.getResponse<Response>()
const status = exception.getStatus()
const isProduction = this.configService.get('env') === Env.PRODUCTION

this.log(exception)

response.status(status).json(
isProduction
? {
statusCode: status,
message: exception.message,
context: exception.context
}
: {
statusCode: status,
message: exception.message,
context: exception.context,
stack: exception.stack,
...(exception.origin && { origin: exception.origin })
}
)
}

// TODO (@wcalderipe, 16/01/24): Unit test the logging logic. For that, we
// must inject the logger in the constructor via dependency injection.
private log(exception: ApplicationException) {
const level: LogLevel = exception.getStatus() >= 500 ? 'error' : 'warn'

if (this.logger[level]) {
this.logger[level](exception.message, {
status: exception.getStatus(),
context: exception.context,
stacktrace: exception.stack,
origin: exception.origin
})
}
}
}
39 changes: 39 additions & 0 deletions apps/vault/src/shared/filter/zod-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { Response } from 'express'
import { ZodError } from 'zod'
import { Config, Env } from '../../main.config'

@Catch(ZodError)
export class ZodExceptionFilter implements ExceptionFilter {
private logger = new Logger(ZodExceptionFilter.name)

constructor(private configService: ConfigService<Config, true>) {}

catch(exception: ZodError, host: ArgumentsHost) {
const ctx = host.switchToHttp()
const response = ctx.getResponse<Response>()
const status = HttpStatus.UNPROCESSABLE_ENTITY
const isProduction = this.configService.get('env') === Env.PRODUCTION

// Log as error level because Zod issues should be handled by the caller.
this.logger.error('Uncaught ZodError', {
exception
})

response.status(status).json(
isProduction
? {
statusCode: status,
message: 'Internal validation error',
context: exception.errors
}
: {
statusCode: status,
message: 'Internal validation error',
context: exception.errors,
stacktrace: exception.stack
}
)
}
}
117 changes: 117 additions & 0 deletions apps/vault/src/shared/guard/authorization.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { PublicKey, hash, hexToBase64Url, verifyJwsd, verifyJwt } from '@narval/signature'
import { CanActivate, ExecutionContext, HttpStatus, Injectable } from '@nestjs/common'
import { z } from 'zod'
import { REQUEST_HEADER_CLIENT_ID } from '../../main.constant'
import { TenantService } from '../../tenant/core/service/tenant.service'
import { ApplicationException } from '../exception/application.exception'

const AuthorizationHeaderSchema = z.object({
authorization: z.string()
})

@Injectable()
export class AuthorizationGuard implements CanActivate {
constructor(private tenantService: TenantService) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest()
const clientId = req.headers[REQUEST_HEADER_CLIENT_ID]
const headers = AuthorizationHeaderSchema.parse(req.headers)
// Expect the header in the format "GNAP <token>"
const accessToken: string | undefined = headers.authorization.split('GNAP ')[1]

if (!accessToken) {
throw new ApplicationException({
message: `Missing or invalid Access Token in Authorization header`,
suggestedHttpStatusCode: HttpStatus.UNAUTHORIZED
})
}

if (!clientId) {
throw new ApplicationException({
message: `Missing or invalid ${REQUEST_HEADER_CLIENT_ID} header`,
suggestedHttpStatusCode: HttpStatus.UNAUTHORIZED
})
}

const tenant = await this.tenantService.findByClientId(clientId)
if (!tenant?.engineJwk) {
throw new ApplicationException({
message: 'No engine key configured',
suggestedHttpStatusCode: HttpStatus.UNAUTHORIZED,
context: {
clientId
}
})
}
const isAuthorized = await this.validateToken(context, accessToken, tenant?.engineJwk)

return isAuthorized
}

async validateToken(context: ExecutionContext, token: string, tenantJwk: PublicKey): Promise<boolean> {
// 1. Validate the JWT has a valid signature for the expected tenant key
const { payload } = await verifyJwt(token, tenantJwk)
// console.log('Validated', { header, payload })

// 2. Validate the TX Request sent is the same as the one in the JWT
const req = context.switchToHttp().getRequest()
const request = req.body.request
const verificationMsg = hash(request)
const requestMatches = payload.requestHash === verificationMsg
if (!requestMatches) {
throw new ApplicationException({
message: `Request payload does not match the authorized request`,
suggestedHttpStatusCode: HttpStatus.FORBIDDEN
})
}

// 3. Validate that the JWT has all the corerct properties, claims, etc.
// This belongs in the signature lib, but needs to accept a options obj like jose does.
// We probs have to roll our own simply so we can support EIP191
// const v = await jwtVerify(token, await importJWK(tenantJwk))
// console.log('JWT Verified', v)

// We want to also check the client key in cnf so we can optionally do bound requests
if (payload.cnf) {
const boundKey = payload.cnf
const jwsdHeader = req.headers['detached-jws']
if (!jwsdHeader) {
throw new ApplicationException({
message: `Missing detached-jws header`,
suggestedHttpStatusCode: HttpStatus.FORBIDDEN
})
}

const parts = jwsdHeader.split('.')
const deepCopyBody = JSON.parse(JSON.stringify(req.body))
// This is the GNAP spec; base64URL the sha256 of the whole request body (not just our tx body.request part)
const jwsdPayload = hexToBase64Url(`0x${hash(deepCopyBody)}`)
// Replace the payload part; this lets the JWT be compacted with `header..signature` to be shorter.
parts[1] = jwsdPayload
const jwsdToVerify = parts.join('.')
// Will throw if not valid
try {
const decodedJwsd = await verifyJwsd(jwsdToVerify, boundKey)
// Verify the ATH matches our accessToken
const tokenHash = hexToBase64Url(`0x${hash(token)}`)
if (decodedJwsd.header.ath !== tokenHash) {
throw new ApplicationException({
message: `Request ath does not match the access token`,
suggestedHttpStatusCode: HttpStatus.FORBIDDEN
})
}
} catch (err) {
throw new ApplicationException({
message: err.message,
suggestedHttpStatusCode: HttpStatus.FORBIDDEN
})
}
// TODO: verify the request URI & such in the jwsd header
}

// Then we sign.

return true
}
}
2 changes: 2 additions & 0 deletions apps/vault/src/shared/schema/tenant.schema.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { publicKeySchema } from '@narval/signature'
import { z } from 'zod'

export const tenantSchema = z.object({
clientId: z.string(),
clientSecret: z.string(),
engineJwk: publicKeySchema.optional(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date()
})
Expand Down
Loading

0 comments on commit 14060ce

Please sign in to comment.