Skip to content

Commit

Permalink
JWT creation / verification (#150)
Browse files Browse the repository at this point in the history
* porting over changes related to jwt creation/verification in pr 122

* fixing rfq creation in client.spec.ts per protocol changes

* using devtools in client.spec.ts

* using devtools for tests

* adding changeset

* removing todos waiting on a merged PR

* removing some stale / irrelevant todos

* removing unused error from index import. changed DevTools.getOffering() to allow passing in of offering creator did

* removing an error import

* removing a test that is no longer needed

* updating tbdex submodule commit pointer to latest in main

Co-authored-by: Moe Jangda <[email protected]>
  • Loading branch information
jiyoontbd and mistermoe authored Jan 26, 2024
1 parent 550fe94 commit 01fc636
Show file tree
Hide file tree
Showing 9 changed files with 344 additions and 62 deletions.
6 changes: 6 additions & 0 deletions .changeset/soft-ravens-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@tbdex/http-client": minor
"@tbdex/http-server": minor
---

JWT creation and verification
147 changes: 116 additions & 31 deletions packages/http-client/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { JwtPayload } from '@web5/crypto'
import type { ErrorDetail } from './types.js'
import type { PortableDid } from '@web5/dids'
import type {
Expand All @@ -9,11 +10,47 @@ import type {
MessageKindClass,
} from '@tbdex/protocol'

import { resolveDid, Offering, Resource, Message, Crypto } from '@tbdex/protocol'
import {
RequestError,
ResponseError,
InvalidDidError,
MissingServiceEndpointError,
RequestTokenMissingClaimsError,
RequestTokenAudienceMismatchError,
RequestTokenSigningError,
RequestTokenVerificationError
} from './errors/index.js'
import { resolveDid, Offering, Resource, Message } from '@tbdex/protocol'
import { utils as didUtils } from '@web5/dids'
import { Convert } from '@web5/common'
import { RequestError, ResponseError, InvalidDidError, MissingServiceEndpointError } from './errors/index.js'
import { typeid } from 'typeid-js'
import { Jwt } from '@web5/credentials'

import queryString from 'query-string'
import ms from 'ms'

/**
* Parameters for generating a request token
* @beta
*/
export type GenerateRequestTokenParams = {
requesterDid: PortableDid
pfiDid: string
}

/**
* Parameters for verifying a request token
* @beta
*/
export type VerifyRequestTokenParams = {
requestToken: string
pfiDid: string
}

/**
* Required jwt claims expected in a request token
* @beta
*/
export const requestTokenRequiredClaims = ['aud', 'iss', 'exp', 'iat', 'jti']

/**
* HTTP client for interacting with TBDex PFIs
Expand Down Expand Up @@ -44,7 +81,7 @@ export class TbdexHttpClient {
headers : { 'content-type': 'application/json' },
body : JSON.stringify(jsonMessage)
})
} catch(e) {
} catch (e) {
throw new RequestError({ message: `Failed to send message to ${pfiDid}`, recipientDid: pfiDid, url: apiRoute, cause: e })
}

Expand Down Expand Up @@ -110,16 +147,16 @@ export class TbdexHttpClient {
* @beta
*/
static async getOfferings(opts: GetOfferingsOptions): Promise<Offering[]> {
const { pfiDid , filter } = opts
const { pfiDid, filter } = opts

const pfiServiceEndpoint = await TbdexHttpClient.getPfiServiceEndpoint(pfiDid)
const queryParams = filter ? `?${queryString.stringify(filter)}`: ''
const queryParams = filter ? `?${queryString.stringify(filter)}` : ''
const apiRoute = `${pfiServiceEndpoint}/offerings${queryParams}`

let response: Response
try {
response = await fetch(apiRoute)
} catch(e) {
} catch (e) {
throw new RequestError({ message: `Failed to get offerings from ${pfiDid}`, recipientDid: pfiDid, url: apiRoute, cause: e })
}

Expand Down Expand Up @@ -148,7 +185,7 @@ export class TbdexHttpClient {

const pfiServiceEndpoint = await TbdexHttpClient.getPfiServiceEndpoint(pfiDid)
const apiRoute = `${pfiServiceEndpoint}/exchanges/${exchangeId}`
const requestToken = await TbdexHttpClient.generateRequestToken(did)
const requestToken = await TbdexHttpClient.generateRequestToken({ requesterDid: did, pfiDid })

let response: Response
try {
Expand All @@ -157,7 +194,7 @@ export class TbdexHttpClient {
authorization: `Bearer ${requestToken}`
}
})
} catch(e) {
} catch (e) {
throw new RequestError({ message: `Failed to get exchange from ${pfiDid}`, recipientDid: pfiDid, url: apiRoute, cause: e })
}

Expand Down Expand Up @@ -186,9 +223,9 @@ export class TbdexHttpClient {
const { pfiDid, filter, did } = opts

const pfiServiceEndpoint = await TbdexHttpClient.getPfiServiceEndpoint(pfiDid)
const queryParams = filter ? `?${queryString.stringify(filter)}`: ''
const queryParams = filter ? `?${queryString.stringify(filter)}` : ''
const apiRoute = `${pfiServiceEndpoint}/exchanges${queryParams}`
const requestToken = await TbdexHttpClient.generateRequestToken(did)
const requestToken = await TbdexHttpClient.generateRequestToken({ requesterDid: did, pfiDid })

let response: Response
try {
Expand All @@ -197,7 +234,7 @@ export class TbdexHttpClient {
authorization: `Bearer ${requestToken}`
}
})
} catch(e) {
} catch (e) {
throw new RequestError({ message: `Failed to get exchanges from ${pfiDid}`, recipientDid: pfiDid, url: apiRoute, cause: e })
}

Expand Down Expand Up @@ -230,7 +267,7 @@ export class TbdexHttpClient {
static async getPfiServiceEndpoint(did: string) {
try {
const didDocument = await resolveDid(did)
const [ didService ] = didUtils.getServices({ didDocument, type: 'PFI' })
const [didService] = didUtils.getServices({ didDocument, type: 'PFI' })

if (!didService?.serviceEndpoint) {
throw new MissingServiceEndpointError(`${did} has no PFI service entry`)
Expand All @@ -246,27 +283,75 @@ export class TbdexHttpClient {
}

/**
* generates a jws to be used to authenticate GET requests
* @param did - the requester's did
*/
static async generateRequestToken(did: PortableDid): Promise<string> {
// TODO: include exp property. expires 1 minute from generation time
// TODO: include aud property. should be DID of receipient
// TODO: include nbf property. not before current time
// TODO: include iss property. should be requester's did
const payload = { timestamp: new Date().toISOString() }
const payloadBytes = Convert.object(payload).toUint8Array()

return Crypto.sign({ did: did, payload: payloadBytes, detached: false })
* Creates and signs a request token ([JWT](https://datatracker.ietf.org/doc/html/rfc7519))
* that's included as the value of Authorization header for requests sent to a PFI API's
* endpoints that require authentication
*
* JWT payload with the following claims:
* * `aud`
* * `iss`
* * `exp`
* * `iat`
* * `jti`The JWT is then signed and returned.
*
* @returns the request token (JWT)
* @throws {RequestTokenError} If an error occurs during the token generation.

Check warning on line 298 in packages/http-client/src/client.ts

View workflow job for this annotation

GitHub Actions / tbdocs-reporter

docs: tsdoc-escape-right-brace

The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag

Check warning on line 298 in packages/http-client/src/client.ts

View workflow job for this annotation

GitHub Actions / tbdocs-reporter

docs: tsdoc-malformed-inline-tag

Expecting a TSDoc tag starting with "{@"

Check warning on line 298 in packages/http-client/src/client.ts

View workflow job for this annotation

GitHub Actions / build-docs

docs: tsdoc-escape-right-brace

The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag

Check warning on line 298 in packages/http-client/src/client.ts

View workflow job for this annotation

GitHub Actions / build-docs

docs: tsdoc-malformed-inline-tag

Expecting a TSDoc tag starting with "{@"
*/
static async generateRequestToken(params: GenerateRequestTokenParams): Promise<string> {
const now = Date.now()
const exp = (now + ms('1m'))

const jwtPayload: JwtPayload = {
aud : params.pfiDid,
iss : params.requesterDid.did,
exp : Math.floor(exp / 1000),
iat : Math.floor(now / 1000),
jti : typeid().getSuffix()
}

try {
return await Jwt.sign({ signerDid: params.requesterDid, payload: jwtPayload })
} catch(e) {
throw new RequestTokenSigningError({ message: e.message, cause: e })
}
}

/**
* validates the bearer token and verifies the cryptographic signature
* @throws if the token is invalid
* @throws see {@link @tbdex/protocol#Crypto.verify}
*/
static async verify(requestToken: string): Promise<string> {
return Crypto.verify({ signature: requestToken })
* Validates and verifies the integrity of a request token ([JWT](https://datatracker.ietf.org/doc/html/rfc7519))
* generated by {@link generateRequestToken}. Specifically:
* * verifies integrity of the JWT
* * ensures all required claims are present and valid.
* * ensures the token has not expired
* * ensures token audience matches the expected PFI DID.
*
* @returns the requester's DID as a string if the token is valid.
* @throws {RequestTokenError} If the token is invalid, expired, or has been tampered with

Check warning on line 328 in packages/http-client/src/client.ts

View workflow job for this annotation

GitHub Actions / tbdocs-reporter

docs: tsdoc-escape-right-brace

The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag

Check warning on line 328 in packages/http-client/src/client.ts

View workflow job for this annotation

GitHub Actions / tbdocs-reporter

docs: tsdoc-malformed-inline-tag

Expecting a TSDoc tag starting with "{@"

Check warning on line 328 in packages/http-client/src/client.ts

View workflow job for this annotation

GitHub Actions / build-docs

docs: tsdoc-escape-right-brace

The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag

Check warning on line 328 in packages/http-client/src/client.ts

View workflow job for this annotation

GitHub Actions / build-docs

docs: tsdoc-malformed-inline-tag

Expecting a TSDoc tag starting with "{@"
*/
static async verifyRequestToken(params: VerifyRequestTokenParams): Promise<string> {

Check warning on line 330 in packages/http-client/src/client.ts

View workflow job for this annotation

GitHub Actions / tbdocs-reporter

extractor: ae-unresolved-link

The `@link` reference could not be resolved: The package "`@tbdex`/http-client" does not have an export "generateRequestToken"

Check warning on line 330 in packages/http-client/src/client.ts

View workflow job for this annotation

GitHub Actions / build-docs

extractor: ae-unresolved-link

The `@link` reference could not be resolved: The package "`@tbdex`/http-client" does not have an export "generateRequestToken"
let result

try {
result = await Jwt.verify({ jwt: params.requestToken })
} catch(e) {
throw new RequestTokenVerificationError({ message: e.message, cause: e })
}

const { payload: requestTokenPayload } = result

// check to ensure all expected claims are present
for (let claim of requestTokenRequiredClaims) {
if (!requestTokenPayload[claim]) {
throw new RequestTokenMissingClaimsError({ message: `Request token missing ${claim} claim. Expected ${requestTokenRequiredClaims}.` })
}
}

// TODO: decide if we want to ensure that the expiration date is not longer than 1 minute after the issuance date

if (requestTokenPayload.aud !== params.pfiDid) {
throw new RequestTokenAudienceMismatchError({ message: 'Request token contains invalid audience. Expected aud property to be PFI DID.' })
}

return requestTokenPayload.iss
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/http-client/src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { RequestError } from './request-error.js'
export { ResponseError } from './response-error.js'
export { ValidationError, InvalidDidError, MissingServiceEndpointError } from './validation-error.js'
export { ValidationError, InvalidDidError, MissingServiceEndpointError } from './validation-error.js'
export { RequestTokenError, RequestTokenSigningError, RequestTokenVerificationError, RequestTokenMissingClaimsError, RequestTokenAudienceMismatchError } from './request-token-error.js'
68 changes: 68 additions & 0 deletions packages/http-client/src/errors/request-token-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// TODO: decide whether this should be a ValidationError

export type RequestTokenErrorParams = {
message: string
cause?: unknown
}

/**
* Error thrown for request token related things
* @beta
*/
export class RequestTokenError extends Error {
constructor(params: RequestTokenErrorParams) {
super(params.message, { cause: params.cause })

this.name = this.constructor.name

Object.setPrototypeOf(this, RequestTokenError.prototype)
}
}

/**
* Error thrown when a request token cannot be signed
* @beta
*/
export class RequestTokenSigningError extends RequestTokenError {
constructor(params: RequestTokenErrorParams) {
super(params)

Object.setPrototypeOf(this, RequestTokenSigningError.prototype)
}
}

/**
* Error thrown when a request token cannot be verified
* @beta
*/
export class RequestTokenVerificationError extends RequestTokenError {
constructor(params: RequestTokenErrorParams) {
super(params)

Object.setPrototypeOf(this, RequestTokenVerificationError.prototype)
}
}

/**
* Error thrown when a request token is missing required claims
* @beta
*/
export class RequestTokenMissingClaimsError extends RequestTokenError {
constructor(params: RequestTokenErrorParams) {
super(params)

Object.setPrototypeOf(this, RequestTokenMissingClaimsError.prototype)
}
}

/**
* Error thrown when a request token aud does not match the PFI did for which its intended
* @beta
*/
export class RequestTokenAudienceMismatchError extends RequestTokenError {
constructor(params: RequestTokenErrorParams) {
super(params)

Object.setPrototypeOf(this, RequestTokenAudienceMismatchError.prototype)
}
}
Loading

0 comments on commit 01fc636

Please sign in to comment.