Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
lib/
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
lib/
48 changes: 24 additions & 24 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -95,15 +95,15 @@ 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
< Date: Sun, 16 Mar 2025 23:11:03 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
< Transfer-Encoding: chunked
<
<
* Connection #0 to host localhost left intact
Token has expired
```
Expand All @@ -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',
Expand Down
35 changes: 34 additions & 1 deletion src/cat.ts
Original file line number Diff line number Diff line change
@@ -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 } = {
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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<boolean> {
this.validateTypes();

if (
this.payload.get(claimsToLabels['iss']) &&
this.payload.get(claimsToLabels['iss']) !== opts.issuer
Expand All @@ -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;
}

Expand Down
12 changes: 12 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -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'}`);
Expand All @@ -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(', ')}`);
}
}
67 changes: 43 additions & 24 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -29,12 +33,12 @@
)
}
});
const cat = await validator.validate(base64encoded!, 'mac', {

Check warning on line 36 in src/index.test.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion
kid: 'Symmetric256',
issuer: 'coap://as.example.com'
});
expect(cat).toBeDefined();
expect(cat!.claims).toEqual({

Check warning on line 41 in src/index.test.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion
iss: 'coap://as.example.com'
});
});
Expand All @@ -55,7 +59,7 @@
issuer: 'coap://as.example.com'
});
expect(cat).toBeDefined();
expect(cat!.claims).toEqual({

Check warning on line 62 in src/index.test.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion
iss: 'coap://as.example.com'
});
});
Expand All @@ -77,24 +81,32 @@
issuer: 'coap://jonas.example.com'
});
expect(cat).toBeDefined();
expect(cat!.claims).toEqual({

Check warning on line 84 in src/index.test.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion
iss: 'coap://jonas.example.com',
iat: 1741985961,
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',
Expand All @@ -106,15 +118,6 @@
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'
Expand All @@ -125,20 +128,36 @@
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',
issuer: 'eyevinn'
})
).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);
});
});
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface CatValidationOptions {
alg?: string;
kid: string;
issuer: string;
audience?: string[];
}

export interface CatGenerateOptions {
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@
"moduleResolution": "node"
},
"exclude": ["**/*.test.ts"],
"include": ["./src"],
"include": ["./src"]
}
Loading