diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..c3af857 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +lib/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c3af857 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +lib/ diff --git a/readme.md b/readme.md index c7dd11d..196d4fd 100644 --- a/readme.md +++ b/readme.md @@ -26,26 +26,26 @@ This is a Node library for generating and validating Common Access Tokens (CTA-5 ### Core Claims -| Claim | Validate | -| ----- | ------ | -| Issuer (`iss`) | Yes | -| Audience (`aud`) | No | -| Expiration (`exp`) | Yes | -| Not Before (`nbf`) | No | -| CWT ID (`cti`) | No | -| Common Access Token Replay (`catreplay`) | No | -| Common Access Token Probability of Rejection (`catpor`) | No | -| Common Access Token Version (`catv`) | No | -| Common Access Token Network IP (`catnip`) | No | -| Common Access Token URI (`catu`) | No | -| Common Access Token Methods (`catm`) | No | -| Common Access Token ALPN (`catalpn`) | No | -| Common Access Token Header (`cath`) | No | -| Common Access Token Geographic ISO3166 (`catgeoiso3166`) | No | -| Common Access Token Geographic Coordinate (`catgeocoord`) | No | -| Geohash (`geohash`) | No | -| Common Access Token Altitude (`catgeoalt`) | No | -| Common Access Token TLS Public Key (`cattpk`) | No | +| Claim | Validate | +| --------------------------------------------------------- | -------- | +| Issuer (`iss`) | Yes | +| Audience (`aud`) | Yes | +| Expiration (`exp`) | Yes | +| Not Before (`nbf`) | No | +| CWT ID (`cti`) | No | +|  Common Access Token Replay (`catreplay`) | No | +| Common Access Token Probability of Rejection (`catpor`) | No | +| Common Access Token Version (`catv`) | No | +| Common Access Token Network IP (`catnip`) |  No | +| Common Access Token URI (`catu`) | No | +| Common Access Token Methods (`catm`) | No | +| Common Access Token ALPN (`catalpn`) |  No | +|  Common Access Token Header (`cath`) | No | +| Common Access Token Geographic ISO3166 (`catgeoiso3166`) |  No | +| Common Access Token Geographic Coordinate (`catgeocoord`) | No | +|  Geohash (`geohash`) | No | +|  Common Access Token Altitude (`catgeoalt`) |  No | +|  Common Access Token TLS Public Key (`cattpk`) | No | ## Requirements @@ -95,7 +95,7 @@ server.listen(8080, '127.0.0.1', () => { > User-Agent: curl/8.7.1 > Accept: */* > CTA-Common-Access-Token: 2D3RhEOhAQWhBFBha2FtYWlfa2V5X2hzMjU2U6MEGmfXP_YGGmfXQAsFGmfXQAtYINTT_KlOyhaV6NaSxFXkqJWfBagSkPkem10dysoA-C0w -> +> * Request completely sent off < HTTP/1.1 401 Unauthorized < Content-Type: text/plain @@ -103,7 +103,7 @@ server.listen(8080, '127.0.0.1', () => { < Connection: keep-alive < Keep-Alive: timeout=5 < Transfer-Encoding: chunked -< +< * Connection #0 to host localhost left intact Token has expired ``` @@ -119,10 +119,10 @@ const validator = new CAT({ '403697de87af64611c1d32a05dab0fe1fcb715a86ab435f1ec99192d79569388', 'hex' ) - }, + } }); const base64encoded = - '0YRDoQEEoQRMU3ltbWV0cmljMjU2eKZkOTAxMDNhNzAxNzU2MzZmNjE3MDNhMmYyZjYxNzMyZTY1Nzg2MTZkNzA2YzY1MmU2MzZmNmQwMjY1NmE2ZjZlNjE3MzAzNzgxODYzNmY2MTcwM2EyZjJmNmM2OTY3Njg3NDJlNjU3ODYxNmQ3MDZjNjUyZTYzNmY2ZDA0MWE1NjEyYWViMDA1MWE1NjEwZDlmMDA2MWE1NjEwZDlmMDA3NDIwYjcxSKuCk/+kFmlY' + '0YRDoQEEoQRMU3ltbWV0cmljMjU2eKZkOTAxMDNhNzAxNzU2MzZmNjE3MDNhMmYyZjYxNzMyZTY1Nzg2MTZkNzA2YzY1MmU2MzZmNmQwMjY1NmE2ZjZlNjE3MzAzNzgxODYzNmY2MTcwM2EyZjJmNmM2OTY3Njg3NDJlNjU3ODYxNmQ3MDZjNjUyZTYzNmY2ZDA0MWE1NjEyYWViMDA1MWE1NjEwZDlmMDA2MWE1NjEwZDlmMDA3NDIwYjcxSKuCk/+kFmlY'; try { const cat = await validator.validate(base64encoded, 'mac', { kid: 'Symmetric256', diff --git a/src/cat.ts b/src/cat.ts index 8282f8e..a05f39f 100644 --- a/src/cat.ts +++ b/src/cat.ts @@ -1,6 +1,11 @@ import * as cbor from 'cbor-x'; import cose from 'cose-js'; -import { InvalidIssuerError, TokenExpiredError } from './errors'; +import { + InvalidAudienceError, + InvalidClaimTypeError, + InvalidIssuerError, + TokenExpiredError +} from './errors'; import { CatValidationOptions } from '.'; const claimsToLabels: { [key: string]: number } = { @@ -65,6 +70,12 @@ const claimTransformReverse: { [key: string]: (value: Buffer) => string } = { cattpk: (value: Buffer) => value.toString('hex') }; +const claimTypeValidators: { [key: string]: (value: string) => boolean } = { + iss: (value) => typeof value === 'string', + exp: (value) => typeof value === 'number', + aud: (value) => typeof value === 'string' || Array.isArray(value) +}; + const CWT_TAG = 61; export type CommonAccessTokenClaims = { [key: string]: string | number }; @@ -190,7 +201,20 @@ export class CommonAccessToken { return this; } + private async validateTypes() { + for (const [key, value] of this.payload) { + const claim = labelsToClaim[key]; + if (claimTypeValidators[claim]) { + if (!claimTypeValidators[claim](value as string)) { + throw new InvalidClaimTypeError(claim, value as string); + } + } + } + } + public async isValid(opts: CatValidationOptions): Promise { + this.validateTypes(); + if ( this.payload.get(claimsToLabels['iss']) && this.payload.get(claimsToLabels['iss']) !== opts.issuer @@ -203,6 +227,15 @@ export class CommonAccessToken { ) { throw new TokenExpiredError(); } + if (opts.audience) { + const value = this.payload.get(claimsToLabels['aud']); + if (value) { + const claimAud = Array.isArray(value) ? value : [value]; + if (!opts.audience.some((item) => claimAud.includes(item))) { + throw new InvalidAudienceError(claimAud as string[]); + } + } + } return true; } diff --git a/src/errors.ts b/src/errors.ts index da9a55e..682b10b 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,5 +1,11 @@ import { CommonAccessTokenValue } from './cat'; +export class InvalidClaimTypeError extends Error { + constructor(claim: string, value: CommonAccessTokenValue) { + super(`Invalid claim type for ${claim}: ${typeof value}`); + } +} + export class InvalidIssuerError extends Error { constructor(issuer: CommonAccessTokenValue | undefined) { super(`Invalid issuer: ${issuer || 'undefined'}`); @@ -11,3 +17,9 @@ export class TokenExpiredError extends Error { super('Token has expired'); } } + +export class InvalidAudienceError extends Error { + constructor(audience: string[]) { + super(`Invalid audience: ${audience.join(', ')}`); + } +} diff --git a/src/index.test.ts b/src/index.test.ts index c912636..e5a587c 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,5 +1,9 @@ import { CAT } from '.'; -import { InvalidIssuerError, TokenExpiredError } from './errors'; +import { + InvalidAudienceError, + InvalidIssuerError, + TokenExpiredError +} from './errors'; describe('CAT', () => { test('can generate a token and verify it', async () => { @@ -83,18 +87,26 @@ describe('CAT', () => { nbf: 1741985961 }); }); +}); - test('fail if wrong issuer', async () => { - const base64encoded = - '0YRDoQEEoQRMU3ltbWV0cmljMjU2eKZkOTAxMDNhNzAxNzU2MzZmNjE3MDNhMmYyZjYxNzMyZTY1Nzg2MTZkNzA2YzY1MmU2MzZmNmQwMjY1NmE2ZjZlNjE3MzAzNzgxODYzNmY2MTcwM2EyZjJmNmM2OTY3Njg3NDJlNjU3ODYxNmQ3MDZjNjUyZTYzNmY2ZDA0MWE1NjEyYWViMDA1MWE1NjEwZDlmMDA2MWE1NjEwZDlmMDA3NDIwYjcxSKuCk/+kFmlY'; - const validator = new CAT({ +describe('CAT claims', () => { + let validator: CAT; + + beforeEach(() => { + validator = new CAT({ keys: { Symmetric256: Buffer.from( '403697de87af64611c1d32a05dab0fe1fcb715a86ab435f1ec99192d79569388', 'hex' ) - } + }, + expectCwtTag: true }); + }); + + test('fail if wrong issuer', async () => { + const base64encoded = + '2D3RhEOhAQWhBFBha2FtYWlfa2V5X2hzMjU2VKMBZWpvbmFzBhpn12U-BRpn12U-WCDf1xHvhcnvyXUxd-DP4RAbayc8nC2PLJPPPbF3S00ruw'; await expect( validator.validate(base64encoded, 'mac', { kid: 'Symmetric256', @@ -106,15 +118,6 @@ describe('CAT', () => { test('pass if token has not expired', async () => { const base64encoded = '2D3RhEOhAQWhBFBha2FtYWlfa2V5X2hzMjU2U6MEGnUCOrsGGmfXRKwFGmfXRKxYIOM6yRx830uqAamWFv1amFYRa5vaV2z5lIQTqFEvFh8z'; - const validator = new CAT({ - keys: { - Symmetric256: Buffer.from( - '403697de87af64611c1d32a05dab0fe1fcb715a86ab435f1ec99192d79569388', - 'hex' - ) - }, - expectCwtTag: true - }); const cat = validator.validate(base64encoded, 'mac', { kid: 'Symmetric256', issuer: 'eyevinn' @@ -125,15 +128,6 @@ describe('CAT', () => { test('fail if token expired', async () => { const base64encoded = '2D3RhEOhAQWhBFBha2FtYWlfa2V5X2hzMjU2U6MEGmfXP_YGGmfXQAsFGmfXQAtYINTT_KlOyhaV6NaSxFXkqJWfBagSkPkem10dysoA-C0w'; - const validator = new CAT({ - keys: { - Symmetric256: Buffer.from( - '403697de87af64611c1d32a05dab0fe1fcb715a86ab435f1ec99192d79569388', - 'hex' - ) - }, - expectCwtTag: true - }); await expect( validator.validate(base64encoded, 'mac', { kid: 'Symmetric256', @@ -141,4 +135,29 @@ describe('CAT', () => { }) ).rejects.toThrow(TokenExpiredError); }); + + test('pass if token has a valid audience', async () => { + // {"aud": ["one", "two"]} + const base64encoded = + '2D3RhEOhAQWhBFBha2FtYWlfa2V5X2hzMjU2V6MDgmNvbmVjdHdvBhpn12R8BRpn12R8WCAdnSbUN4KbIvaHLn-q4f4YRpfq6ERYotByjbIyZ-EkfQ'; + const cat = validator.validate(base64encoded, 'mac', { + kid: 'Symmetric256', + issuer: 'eyevinn', + audience: ['one', 'three'] + }); + expect(cat).toBeDefined(); + }); + + test('fail if token has an invalid audience', async () => { + // {"aud": ["one", "two"]} + const base64encoded = + '2D3RhEOhAQWhBFBha2FtYWlfa2V5X2hzMjU2V6MDgmNvbmVjdHdvBhpn12R8BRpn12R8WCAdnSbUN4KbIvaHLn-q4f4YRpfq6ERYotByjbIyZ-EkfQ'; + await expect( + validator.validate(base64encoded, 'mac', { + kid: 'Symmetric256', + issuer: 'eyevinn', + audience: ['three'] + }) + ).rejects.toThrow(InvalidAudienceError); + }); }); diff --git a/src/index.ts b/src/index.ts index d98b3a4..d27a922 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ export interface CatValidationOptions { alg?: string; kid: string; issuer: string; + audience?: string[]; } export interface CatGenerateOptions { diff --git a/tsconfig.json b/tsconfig.json index d64e157..6a3aa0a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,5 @@ "moduleResolution": "node" }, "exclude": ["**/*.test.ts"], - "include": ["./src"], + "include": ["./src"] }