-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
3a53971
commit 14060ce
Showing
23 changed files
with
652 additions
and
82 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
82 changes: 82 additions & 0 deletions
82
apps/vault/src/shared/filter/__test__/unit/application-exception.filter.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
52
apps/vault/src/shared/filter/application-exception.filter.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.