Skip to content

Commit

Permalink
Adding aud, iss, age, baseUrl config & client configs for jwt verific…
Browse files Browse the repository at this point in the history
…ation
  • Loading branch information
mattschoch committed Apr 4, 2024
1 parent a52abfe commit 3f6dce7
Show file tree
Hide file tree
Showing 12 changed files with 129 additions and 30 deletions.
3 changes: 3 additions & 0 deletions apps/vault/.env.default
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ MASTER_PASSWORD="unsafe-local-dev-master-password"
KEYRING_TYPE="raw"

# MASTER_AWS_KMS_ARN="arn:aws:kms:us-east-2:728783560968:key/f6aa3ddb-47c3-4f31-977d-b93205bb23d1"

# BaseUrl where the Vault is deployed. Will be used to verify jwsd request signatures
BASE_URL="http://localhost:3011"
2 changes: 2 additions & 0 deletions apps/vault/.env.test.default
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ MASTER_PASSWORD="unsafe-local-test-master-password"
KEYRING_TYPE="raw"

# MASTER_AWS_KMS_ARN="arn:aws:kms:us-east-2:728783560968:key/f6aa3ddb-47c3-4f31-977d-b93205bb23d1"

BASE_URL="https://vault-test.narval.xyz"
8 changes: 6 additions & 2 deletions apps/vault/src/client/__test__/e2e/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,11 @@ describe('Client', () => {
const clientId = uuid()

const payload: CreateClientDto = {
clientId
clientId,
audience: 'https://vault.narval.xyz',
issuer: 'https://auth.narval.xyz',
maxTokenAge: 30,
baseUrl: 'https://vault.narval.xyz'
}

it('creates a new client', async () => {
Expand All @@ -82,7 +86,7 @@ describe('Client', () => {
const actualClient = await clientRepository.findByClientId(clientId)

expect(body).toMatchObject({
clientId,
...payload,
clientSecret: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export class ClientController {
clientId: body.clientId || uuid(),
clientSecret: randomBytes(42).toString('hex'),
engineJwk,
audience: body.audience,
issuer: body.issuer,
maxTokenAge: body.maxTokenAge,
baseUrl: body.baseUrl,
createdAt: now,
updatedAt: now
})
Expand Down
22 changes: 21 additions & 1 deletion apps/vault/src/client/http/rest/dto/create-client.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Jwk } from '@narval/signature'
import { ApiPropertyOptional } from '@nestjs/swagger'
import { IsOptional, IsString } from 'class-validator'
import { IsNumber, IsOptional, IsString } from 'class-validator'

export class CreateClientDto {
@IsString()
Expand All @@ -10,4 +10,24 @@ export class CreateClientDto {

@IsOptional()
engineJwk?: Jwk

@IsString()
@IsOptional()
@ApiPropertyOptional()
audience?: string

@IsString()
@IsOptional()
@ApiPropertyOptional()
issuer?: string

@IsNumber()
@IsOptional()
@ApiPropertyOptional()
maxTokenAge?: number

@IsString()
@IsOptional()
@ApiPropertyOptional()
baseUrl?: string
}
6 changes: 4 additions & 2 deletions apps/vault/src/main.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ const configSchema = z.object({
type: z.literal('awskms'),
masterAwsKmsArn: z.string()
})
])
]),
baseUrl: z.string().optional()
})

export type Config = z.infer<typeof configSchema>
Expand All @@ -47,7 +48,8 @@ export const load = (): Config => {
type: process.env.KEYRING_TYPE,
masterAwsKmsArn: process.env.MASTER_AWS_KMS_ARN,
masterPassword: process.env.MASTER_PASSWORD
}
},
baseUrl: process.env.BASE_URL // Such as "https://vault.narval.xyz"
})

if (result.success) {
Expand Down
57 changes: 35 additions & 22 deletions apps/vault/src/shared/guard/authorization.guard.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,30 @@
import { PublicKey, verifyJwsd, verifyJwt } from '@narval/signature'
import { JwtVerifyOptions, PublicKey, verifyJwsd, verifyJwt } from '@narval/signature'
import { CanActivate, ExecutionContext, HttpStatus, Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { z } from 'zod'
import { ClientService } from '../../client/core/service/client.service'
import { Config } from '../../main.config'
import { REQUEST_HEADER_CLIENT_ID } from '../../main.constant'
import { ApplicationException } from '../exception/application.exception'
import { Client } from '../type/domain.type'

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

const DEFAULT_MAX_TOKEN_AGE = 60

@Injectable()
export class AuthorizationGuard implements CanActivate {
constructor(private clientService: ClientService) {}
constructor(
private clientService: ClientService,
private configService: ConfigService<Config, true>
) {}

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({
Expand All @@ -35,27 +34,40 @@ export class AuthorizationGuard implements CanActivate {
}

const client = await this.clientService.findByClientId(clientId)
if (!client?.engineJwk) {

// Expect the header in the format "GNAP <token>"
const accessToken: string | undefined = headers.authorization.split('GNAP ')[1]

if (!accessToken) {
throw new ApplicationException({
message: 'No engine key configured',
suggestedHttpStatusCode: HttpStatus.UNAUTHORIZED,
context: {
clientId
}
message: `Missing or invalid Access Token in Authorization header`,
suggestedHttpStatusCode: HttpStatus.UNAUTHORIZED
})
}
const isAuthorized = await this.validateToken(context, accessToken, client?.engineJwk)
const isAuthorized = await this.validateToken(context, accessToken, client)

return isAuthorized
}

async validateToken(context: ExecutionContext, token: string, clientJwk: PublicKey): Promise<boolean> {
async validateToken(context: ExecutionContext, token: string, client: Client | null): Promise<boolean> {
const req = context.switchToHttp().getRequest()
const request = req.body.request
if (!client?.engineJwk) {
throw new ApplicationException({
message: 'No engine key configured',
suggestedHttpStatusCode: HttpStatus.UNAUTHORIZED
})
}
const clientJwk: PublicKey = client?.engineJwk
const opts: JwtVerifyOptions = {
audience: client?.audience,
issuer: client?.issuer,
maxTokenAge: client?.maxTokenAge || DEFAULT_MAX_TOKEN_AGE
}

// Validate the JWT has a valid signature for the expected client key & the request matches
const { payload } = await verifyJwt(token, clientJwk, {
maxTokenAge: 60, // Verify the token is not older than 60s
...opts,
requestHash: request
}).catch((err) => {
throw new ApplicationException({
Expand All @@ -78,12 +90,13 @@ export class AuthorizationGuard implements CanActivate {

// Will throw if not valid
try {
const defaultBaseUrl = this.configService.get('baseUrl', { infer: true })
await verifyJwsd(jwsdHeader, boundKey, {
requestBody: req.body, // Verify the request body
accessToken: token, // Verify that the ATH matches the access token
uri: `https://armory.narval.xyz${req.url}`, // Verify the request URI // TODO: base url should be dynamic
uri: `${client.baseUrl || defaultBaseUrl}${req.url}`, // Verify the request URI
htm: req.method, // Verify the request method
maxTokenAge: 60 // Verify the token is not older than 60 seconds
maxTokenAge: DEFAULT_MAX_TOKEN_AGE
})
} catch (err) {
throw new ApplicationException({
Expand Down
8 changes: 8 additions & 0 deletions apps/vault/src/shared/schema/client.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ export const clientSchema = z.object({
clientId: z.string(),
clientSecret: z.string(),
engineJwk: publicKeySchema.optional(),

// JWT Verification Options
audience: z.string().optional(),
issuer: z.string().optional(),
maxTokenAge: z.number().optional(),

baseUrl: z.string().optional(), // Override if you want to use a different baseUrl for a single client

createdAt: z.coerce.date(),
updatedAt: z.coerce.date()
})
Expand Down
4 changes: 2 additions & 2 deletions apps/vault/src/vault/__test__/e2e/sign.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ describe('Sign', () => {
kid: clientJwk.kid,
typ: 'gnap-binding-jwsd',
htm: 'POST',
uri: 'https://armory.narval.xyz/sign',
uri: 'https://vault-test.narval.xyz/sign',
created: now,
ath: hexToBase64Url(hash(accessToken))
}
Expand Down Expand Up @@ -326,7 +326,7 @@ describe('Sign', () => {
kid: clientJwk.kid,
typ: 'gnap-binding-jwsd',
htm: 'POST',
uri: 'https://armory.narval.xyz/sign',
uri: 'https://vault-test.narval.xyz/sign',
created: now,
ath: ''
}
Expand Down
27 changes: 27 additions & 0 deletions packages/signature/src/lib/__test__/unit/verify.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { generateJwk, nowSeconds, privateKeyToJwk, secp256k1PrivateKeyToJwk } fr
import { validateJwk } from '../../validate'
import {
checkAudience,
checkAuthorizedParty,
checkDataHash,
checkIssuer,
checkNbf,
Expand Down Expand Up @@ -680,6 +681,32 @@ describe('checkSubject', () => {
})
})

describe('checkAuthorizedParty', () => {
it('returns true when the azp is valid', () => {
const payload: Payload = {
azp: 'my-client-id'
}

expect(
checkAuthorizedParty(payload, {
authorizedParty: 'my-client-id'
})
).toBe(true)
})

it('throws JwtError when the azp is invalid', () => {
const payload: Payload = {
azp: 'my-client-id'
}

const opts = {
authorizedParty: 'invalid-client'
}

expect(() => checkAuthorizedParty(payload, opts)).toThrow(JwtError)
})
})

describe('checkRequestHash', () => {
it('returns true when the requestHash is a string & matches', () => {
const payload: Payload = {
Expand Down
7 changes: 6 additions & 1 deletion packages/signature/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,8 @@ export type JwsdHeader = z.infer<typeof JwsdHeader>
* @param {number} [exp] - The time the JWT expires.
* @param {number} [nbf] - The time the JWT becomes valid.
* @param {string} sub - The subject of the JWT.
* @param {string} [aud] - The audience of the JWT.
* @param {string | string[]} [aud] - The audience of the JWT.
* @param {string} [azp] - The authorized party of the JWT. Typically a client-id.
* @param {string} [jti] - The JWT ID.
* @param {Jwk} cnf - The client-bound key.
*
Expand All @@ -221,6 +222,7 @@ export const Payload = z.intersection(
iss: z.string().optional(),
aud: z.union([z.string(), z.array(z.string())]).optional(),
jti: z.string().optional(),
azp: z.string().optional(),
cnf: publicKeySchema.optional(),
requestHash: z.string().optional(),
data: z.string().optional()
Expand Down Expand Up @@ -257,6 +259,9 @@ export type JwtVerifyOptions = {
/** Expected JWT "sub" (Subject) Claim value. */
subject?: string

/** Expected JWT "azp" (Authorized Party) Claim value. */
authorizedParty?: string

/** Expected JWT "typ" (Type) Header Parameter value. */
typ?: string

Expand Down
11 changes: 11 additions & 0 deletions packages/signature/src/lib/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ export const checkSubject = (payload: Payload, opts: JwtVerifyOptions): boolean
return true
}

export const checkAuthorizedParty = (payload: Payload, opts: JwtVerifyOptions): boolean => {
if (opts.authorizedParty) {
if (!payload.azp || opts.authorizedParty !== payload.azp) {
throw new JwtError({ message: 'Invalid authorized party', context: { payload } })
}
}
return true
}

export const checkRequestHash = (payload: Payload, opts: JwtVerifyOptions): boolean => {
if (opts.requestHash) {
const requestHash = typeof opts.requestHash === 'string' ? opts.requestHash : hash(opts.requestHash)
Expand Down Expand Up @@ -319,6 +328,8 @@ export async function verifyJwt(jwt: string, jwk: Jwk, opts: JwtVerifyOptions =

checkSubject(payload, opts)

checkAuthorizedParty(payload, opts)

checkRequestHash(payload, opts)

checkDataHash(payload, opts)
Expand Down

0 comments on commit 3f6dce7

Please sign in to comment.