Skip to content

Commit efe3c4e

Browse files
authored
feat: implementation of catreplay claim (#17)
* feat: implementation of catreplay claim * chore: documented new errors
1 parent 8e7b56e commit efe3c4e

File tree

4 files changed

+236
-5
lines changed

4 files changed

+236
-5
lines changed

readme.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ Features:
4545
| Expiration (`exp`) | Yes |
4646
| Not Before (`nbf`) | Yes |
4747
| CWT ID (`cti`) | Yes |
48-
| Common Access Token Replay (`catreplay`) | No |
48+
| Common Access Token Replay (`catreplay`) | Yes |
4949
| Common Access Token Probability of Rejection (`catpor`) | No |
5050
| Common Access Token Version (`catv`) | No |
5151
| Common Access Token Network IP (`catnip`) | No |
@@ -307,6 +307,53 @@ class MyLogger implements ITokenLogger {
307307
}
308308
```
309309
310+
## Token Reuse Detection
311+
312+
Enforcing and detecting invalid token reuse when `catreplay` claim has a value of 2 is an implementation-defined process.
313+
314+
You provide a custom callback function to the HTTP validator. This callback is provided with the Common Access Token, store and logger. Pseudo code as an example.
315+
316+
```javascript
317+
class MyLogger implements ITokenLogger {
318+
async logToken(token: CommonAccessToken): Promise<void> {
319+
if (!this.db.connected) {
320+
await this.db.connect();
321+
}
322+
this.db.insert(token);
323+
}
324+
async getLogsForToken(token: CommonAccessToken): Promise<LogList[]> {
325+
return await this.db.find({ cti: token.cti });
326+
}
327+
}
328+
329+
const reuseDetection = async (
330+
cat: CommonAccessToken,
331+
store?: ICTIStore,
332+
logger?: ITokenLogger
333+
) => {
334+
if (logger) {
335+
const logs = await logger.getLogsForToken(cat);
336+
return tokenLogsInPeriod(logs, 5) > 10; // two many uses within a 5 second period
337+
}
338+
}
339+
340+
const httpValidator = new HttpValidator({
341+
keys: [
342+
{
343+
kid: 'Symmetric256',
344+
key: Buffer.from(
345+
'403697de87af64611c1d32a05dab0fe1fcb715a86ab435f1ec99192d79569388',
346+
'hex'
347+
)
348+
}
349+
],
350+
issuer: 'eyevinn',
351+
store: new MyLogger(),
352+
reuseDetection
353+
});
354+
...
355+
```
356+
310357
## Development
311358
312359
<!--Add clear instructions on how to start development of the project here -->

src/errors.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,21 @@ export class RenewalClaimError extends Error {
8080
super(reason);
8181
}
8282
}
83+
84+
/**
85+
* Error thrown when trying to replay a token that is not allowed to be replayed
86+
*/
87+
export class ReplayNotAllowedError extends Error {
88+
constructor(count: number) {
89+
super(`Replay not allowed: ${count}`);
90+
}
91+
}
92+
93+
/**
94+
* Error thrown when a token is detected for invalid reuse
95+
*/
96+
export class InvalidReuseDetected extends Error {
97+
constructor() {
98+
super(`Invalid reuse detected`);
99+
}
100+
}

src/validators/http.test.ts

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { createRequest, createResponse } from 'node-mocks-http';
22
import { HttpValidator, NoTokenFoundError } from './http';
3-
import { CAT, MemoryCTIStore } from '..';
3+
import {
4+
CAT,
5+
CommonAccessToken,
6+
ICTIStore,
7+
ITokenLogger,
8+
MemoryCTIStore
9+
} from '..';
410
import { CommonAccessTokenRenewal } from '../catr';
5-
import { CloudFrontResponse } from 'aws-lambda';
611

712
describe('HTTP Request CAT Validator', () => {
813
test('fail to validate token in CTA-Common-Access-Token header with wrong signature', async () => {
@@ -626,6 +631,7 @@ describe('HTTP Request CAT Validator with store', () => {
626631
expect(result.claims!.cti).toBe('0b71');
627632
expect(result.count).toBe(1);
628633
const result2 = await httpValidator.validateHttpRequest(request, response);
634+
expect(result2.status).toBe(200);
629635
expect(result2.count).toBe(2);
630636

631637
const cfResult = await httpValidator.validateCloudFrontRequest({
@@ -695,4 +701,136 @@ describe('HTTP Request CAT Validator with store', () => {
695701
expect(result.claims!.cti).toBe('0b71');
696702
expect(result.count).toBeUndefined();
697703
});
704+
705+
test('pass if a token has a claim that allows replay and it has been used multiple times', async () => {
706+
const base64encoded = await generator.generateFromJson(
707+
{
708+
iss: 'eyevinn',
709+
catreplay: 0
710+
},
711+
{
712+
type: 'mac',
713+
alg: 'HS256',
714+
kid: 'Symmetric256',
715+
generateCwtId: true
716+
}
717+
);
718+
const httpValidator = new HttpValidator({
719+
keys: [
720+
{
721+
kid: 'Symmetric256',
722+
key: Buffer.from(
723+
'403697de87af64611c1d32a05dab0fe1fcb715a86ab435f1ec99192d79569388',
724+
'hex'
725+
)
726+
}
727+
],
728+
issuer: 'eyevinn',
729+
store: new MemoryCTIStore()
730+
});
731+
const request = createRequest({
732+
method: 'GET',
733+
headers: {
734+
'CTA-Common-Access-Token': base64encoded
735+
}
736+
});
737+
const result = await httpValidator.validateHttpRequest(request);
738+
expect(result.status).toBe(200);
739+
const result2 = await httpValidator.validateHttpRequest(request);
740+
expect(result2.status).toBe(200);
741+
});
742+
743+
test('fail if a token has a claim that does not allow replay and it has been used multiple times', async () => {
744+
const base64encoded = await generator.generateFromJson(
745+
{
746+
iss: 'eyevinn',
747+
catreplay: 1
748+
},
749+
{
750+
type: 'mac',
751+
alg: 'HS256',
752+
kid: 'Symmetric256',
753+
generateCwtId: true
754+
}
755+
);
756+
const httpValidator = new HttpValidator({
757+
keys: [
758+
{
759+
kid: 'Symmetric256',
760+
key: Buffer.from(
761+
'403697de87af64611c1d32a05dab0fe1fcb715a86ab435f1ec99192d79569388',
762+
'hex'
763+
)
764+
}
765+
],
766+
issuer: 'eyevinn',
767+
store: new MemoryCTIStore()
768+
});
769+
const request = createRequest({
770+
method: 'GET',
771+
headers: {
772+
'CTA-Common-Access-Token': base64encoded
773+
}
774+
});
775+
const result = await httpValidator.validateHttpRequest(request);
776+
expect(result.status).toBe(200);
777+
const result2 = await httpValidator.validateHttpRequest(request);
778+
expect(result2.status).toBe(401);
779+
});
780+
781+
test('can provide a simple reuse detection algorithm', async () => {
782+
const base64encoded = await generator.generateFromJson(
783+
{
784+
iss: 'eyevinn',
785+
catreplay: 2
786+
},
787+
{
788+
type: 'mac',
789+
alg: 'HS256',
790+
kid: 'Symmetric256',
791+
generateCwtId: true
792+
}
793+
);
794+
795+
const simpleReuseDetection = async (
796+
cat: CommonAccessToken,
797+
store?: ICTIStore,
798+
logger?: ITokenLogger
799+
) => {
800+
if (store) {
801+
const count = await store.getTokenCount(cat);
802+
// Consider reuse if same token has been used more than 2 times
803+
// (this is a very naive example)
804+
return count > 2;
805+
}
806+
return true;
807+
};
808+
const httpValidator = new HttpValidator({
809+
keys: [
810+
{
811+
kid: 'Symmetric256',
812+
key: Buffer.from(
813+
'403697de87af64611c1d32a05dab0fe1fcb715a86ab435f1ec99192d79569388',
814+
'hex'
815+
)
816+
}
817+
],
818+
issuer: 'eyevinn',
819+
store: new MemoryCTIStore(),
820+
reuseDetection: simpleReuseDetection
821+
});
822+
823+
const request = createRequest({
824+
method: 'GET',
825+
headers: {
826+
'CTA-Common-Access-Token': base64encoded
827+
}
828+
});
829+
const result = await httpValidator.validateHttpRequest(request);
830+
expect(result.status).toBe(200);
831+
const result2 = await httpValidator.validateHttpRequest(request);
832+
expect(result2.status).toBe(200);
833+
const result3 = await httpValidator.validateHttpRequest(request);
834+
expect(result3.status).toBe(401);
835+
});
698836
});

src/validators/http.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { IncomingMessage, OutgoingMessage } from 'node:http';
2-
import { CAT } from '..';
2+
import { CAT, CommonAccessToken } from '..';
33
import {
44
InvalidAudienceError,
55
InvalidIssuerError,
6+
InvalidReuseDetected,
67
KeyNotFoundError,
8+
ReplayNotAllowedError,
79
TokenExpiredError,
810
UriNotAllowedError
911
} from '../errors';
@@ -61,6 +63,14 @@ export interface HttpValidatorOptions {
6163
* Logger for logging token usage
6264
*/
6365
logger?: ITokenLogger;
66+
/**
67+
* Callback for reuse detection
68+
*/
69+
reuseDetection?: (
70+
cat: CommonAccessToken,
71+
store?: ICTIStore,
72+
logger?: ITokenLogger
73+
) => Promise<boolean>;
6474
}
6575

6676
/**
@@ -228,6 +238,22 @@ export class HttpValidator {
228238
// CAT is acceptable
229239
if (cat && this.store) {
230240
count = await this.store.storeToken(cat);
241+
if (cat.claims.catreplay !== undefined) {
242+
if (cat.claims.catreplay === 1) {
243+
if (count > 1) {
244+
throw new ReplayNotAllowedError(count);
245+
}
246+
} else if (
247+
cat.claims.catreplay === 2 &&
248+
this.opts.reuseDetection
249+
) {
250+
if (
251+
await this.opts.reuseDetection(cat, this.store, this.logger)
252+
) {
253+
throw new InvalidReuseDetected();
254+
}
255+
}
256+
}
231257
}
232258
if (cat && this.logger) {
233259
await this.logger.logToken(cat);
@@ -294,7 +320,9 @@ export class HttpValidator {
294320
err instanceof InvalidAudienceError ||
295321
err instanceof KeyNotFoundError ||
296322
err instanceof TokenExpiredError ||
297-
err instanceof UriNotAllowedError
323+
err instanceof UriNotAllowedError ||
324+
err instanceof ReplayNotAllowedError ||
325+
err instanceof InvalidReuseDetected
298326
) {
299327
return {
300328
status: 401,

0 commit comments

Comments
 (0)