Skip to content

Commit 4da21aa

Browse files
authored
feat: support for handling catif claim (#23)
* feat: supporting simple catif claim * feat: support new cat token based on catif directives * chore: updated readme * chore: updated readme with catif example * chore: fix prettier
1 parent e23f7a5 commit 4da21aa

File tree

6 files changed

+295
-7
lines changed

6 files changed

+295
-7
lines changed

readme.md

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ Features:
6060
| Geohash (`geohash`) | Yes | No |
6161
| Common Access Token Altitude (`catgeoalt`) | Yes | No |
6262
| Common Access Token TLS Public Key (`cattpk`) | Yes | No |
63-
| Common Access Token If (`catif`) claim | Yes | No |
63+
| Common Access Token If (`catif`) claim | Yes | Yes |
6464
| Common Access Token Renewal (`catr`) claim | Yes | No |
6565

6666
## Requirements
@@ -204,7 +204,7 @@ try {
204204
### Generate token
205205
206206
```javascript
207-
import { CAT, CommonAccessTokenRenewal } from '@eyevinn/cat';
207+
import { CAT } from '@eyevinn/cat';
208208

209209
const generator = new CAT({
210210
keys: {
@@ -238,6 +238,53 @@ const base64encoded = await generator.generateFromJson(
238238
);
239239
```
240240
241+
This example generates a token with a `catif` claim.
242+
243+
```javascript
244+
import { CAT } from '@eyevinn/cat';
245+
246+
const generator = new CAT({
247+
keys: {
248+
Symmetric256: Buffer.from(
249+
'403697de87af64611c1d32a05dab0fe1fcb715a86ab435f1ec99192d79569388',
250+
'hex'
251+
)
252+
}
253+
});
254+
255+
const json = {
256+
iss: 'eyevinn',
257+
exp: Math.floor(Date.now() / 1000) - 60,
258+
cti: 'foobar',
259+
catif: {
260+
exp: [
261+
307,
262+
{
263+
Location: [
264+
'https://auth.example.net/?CAT=',
265+
{
266+
iss: null,
267+
iat: null,
268+
catu: {
269+
host: { 'exact-match': 'auth.example.net' }
270+
}
271+
}
272+
]
273+
},
274+
'Symmetric256'
275+
]
276+
}
277+
};
278+
const base64encoded = await generator.generateFromJson(json, {
279+
type: 'mac',
280+
alg: 'HS256',
281+
kid: 'Symmetric256',
282+
generateCwtId: true
283+
});
284+
```
285+
286+
Providing above token to the `HttpValidator` the validator will return with a status code `307` and the header `Location` with the value of `https://auth.example.net/?CAT=<newtoken>` where `<newtoken>` is created by the JSON specified in the `catif` claim.
287+
241288
## Token store plugins
242289

243290
To enable token usage count the HTTP validators requires a way to store the token id:s that has been used. The following types of stores are supported today.

src/catif.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { CommonAccessTokenIf } from './catif';
2+
3+
describe('Common Access Token If', () => {
4+
test('can be constructed from a dict', async () => {
5+
const basic = CommonAccessTokenIf.fromDict({
6+
exp: [
7+
307,
8+
{
9+
Location: 'https://auth.example.net/'
10+
}
11+
]
12+
});
13+
expect(basic.toDict()).toEqual({
14+
exp: [307, { Location: 'https://auth.example.net/' }]
15+
});
16+
17+
const advanced = CommonAccessTokenIf.fromDict({
18+
exp: [
19+
307,
20+
{
21+
Location: [
22+
'https://auth.example.net/?CAT=',
23+
{
24+
iss: null,
25+
iat: null
26+
}
27+
]
28+
},
29+
'mykey'
30+
]
31+
});
32+
expect(advanced.toDict()).toEqual({
33+
exp: [
34+
307,
35+
{
36+
Location: [
37+
'https://auth.example.net/?CAT=',
38+
{
39+
iss: null,
40+
iat: null
41+
}
42+
]
43+
},
44+
'mykey'
45+
]
46+
});
47+
});
48+
});

src/catif.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
import { claimsToLabels, labelsToClaim } from './cat';
1+
import { claimsToLabels, CommonAccessTokenDict, labelsToClaim } from './cat';
22

33
type CatIfValue = Map<number, [number, { [key: string]: string }]>;
44
export type CommonAccessTokenIfMap = Map<number, CatIfValue>;
55

6+
export type CatIfDictValue = {
7+
[key: string]: [
8+
number,
9+
{ [header: string]: string | [string, CommonAccessTokenDict] },
10+
string?
11+
];
12+
};
13+
614
export class CommonAccessTokenIf {
715
private catIfMap: CommonAccessTokenIfMap = new Map();
816

src/errors.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,9 @@ export class MethodNotAllowedError extends Error {
110110
super(`HTTP Method ${method} not allowed`);
111111
}
112112
}
113+
114+
export class InvalidCatIfError extends Error {
115+
constructor(reason: string) {
116+
super(reason);
117+
}
118+
}

src/validators/http.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,121 @@ describe('HTTP Request CAT Validator with auto renew', () => {
449449
expect(response.getHeader('cta-common-access-token')).not.toBeDefined();
450450
});
451451

452+
test('can follow the directives in catif claim when no acceptable token is provided', async () => {
453+
const json = {
454+
iss: 'eyevinn',
455+
exp: Math.floor(Date.now() / 1000) - 60,
456+
cti: 'foobar',
457+
catif: {
458+
exp: [
459+
307,
460+
{
461+
Location: 'https://auth.example.net/'
462+
}
463+
]
464+
}
465+
};
466+
base64encoded = await generator.generateFromJson(json, {
467+
type: 'mac',
468+
alg: 'HS256',
469+
kid: 'Symmetric256',
470+
generateCwtId: true
471+
});
472+
// Validate auto renew
473+
const httpValidator = new HttpValidator({
474+
keys: [
475+
{
476+
kid: 'Symmetric256',
477+
key: Buffer.from(
478+
'403697de87af64611c1d32a05dab0fe1fcb715a86ab435f1ec99192d79569388',
479+
'hex'
480+
)
481+
}
482+
],
483+
issuer: 'eyevinn'
484+
});
485+
const request = createRequest({
486+
method: 'GET',
487+
headers: {
488+
'CTA-Common-Access-Token': base64encoded
489+
}
490+
});
491+
const response = createResponse();
492+
const result = await httpValidator.validateHttpRequest(request, response);
493+
expect(response.getHeader('Location')).toBe('https://auth.example.net/');
494+
expect(result.status).toBe(307);
495+
});
496+
497+
test('can generate new cat claims based on catif directives', async () => {
498+
const json = {
499+
iss: 'eyevinn',
500+
exp: Math.floor(Date.now() / 1000) - 60,
501+
cti: 'foobar',
502+
catif: {
503+
exp: [
504+
307,
505+
{
506+
Location: [
507+
'https://auth.example.net/?CAT=',
508+
{
509+
iss: null,
510+
iat: null,
511+
catu: {
512+
host: { 'exact-match': 'auth.example.net' }
513+
}
514+
}
515+
]
516+
},
517+
'Symmetric256'
518+
]
519+
}
520+
};
521+
base64encoded = await generator.generateFromJson(json, {
522+
type: 'mac',
523+
alg: 'HS256',
524+
kid: 'Symmetric256',
525+
generateCwtId: true
526+
});
527+
// Validate auto renew
528+
const httpValidator = new HttpValidator({
529+
keys: [
530+
{
531+
kid: 'Symmetric256',
532+
key: Buffer.from(
533+
'403697de87af64611c1d32a05dab0fe1fcb715a86ab435f1ec99192d79569388',
534+
'hex'
535+
)
536+
}
537+
],
538+
issuer: 'eyevinn'
539+
});
540+
const request = createRequest({
541+
method: 'GET',
542+
headers: {
543+
'CTA-Common-Access-Token': base64encoded
544+
}
545+
});
546+
const response = createResponse();
547+
const result = await httpValidator.validateHttpRequest(request, response);
548+
expect(response.getHeader('Location')).toBeDefined();
549+
const location = new URL(response.getHeader('Location') as string);
550+
const newToken = location.searchParams.get('CAT');
551+
expect(newToken).toBeDefined();
552+
expect(result.status).toBe(307);
553+
const validator = new CAT({
554+
keys: {
555+
Symmetric256: Buffer.from(
556+
'403697de87af64611c1d32a05dab0fe1fcb715a86ab435f1ec99192d79569388',
557+
'hex'
558+
)
559+
}
560+
});
561+
const result2 = await validator.validate(newToken!, 'mac', {
562+
issuer: 'eyevinn'
563+
});
564+
expect(result2.cat?.claims.iat).toBeDefined();
565+
});
566+
452567
test.skip('can handle autorenew of type redirect', async () => {
453568
// Validate auto renew
454569
const httpValidator = new HttpValidator({

src/validators/http.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { IncomingMessage, OutgoingMessage } from 'node:http';
22
import { CAT, CommonAccessToken } from '..';
33
import {
44
InvalidAudienceError,
5+
InvalidCatIfError,
56
InvalidIssuerError,
67
InvalidReuseDetected,
78
KeyNotFoundError,
@@ -15,9 +16,10 @@ import {
1516
CloudFrontRequest,
1617
CloudFrontResponse
1718
} from 'aws-lambda';
18-
import { CommonAccessTokenDict } from '../cat';
19+
import { CommonAccessTokenDict, CommonAccessTokenFactory } from '../cat';
1920
import { ICTIStore } from '../stores/interface';
2021
import { ITokenLogger } from '../loggers/interface';
22+
import { CatIfDictValue } from '../catif';
2123

2224
interface HttpValidatorKey {
2325
kid: string;
@@ -257,8 +259,19 @@ export class HttpValidator {
257259
}
258260
return { status: code, claims: cat?.claims, count };
259261
} else {
262+
// CAT valid but not acceptable
263+
let responseCode = 401;
264+
if (result.error instanceof TokenExpiredError) {
265+
responseCode =
266+
(cat?.claims.catif &&
267+
(await this.handleCatIf({
268+
cat,
269+
response
270+
}))) ||
271+
401;
272+
}
260273
return {
261-
status: 401,
274+
status: responseCode,
262275
message: result.error.message,
263276
claims: cat?.claims
264277
};
@@ -274,8 +287,9 @@ export class HttpValidator {
274287
err instanceof InvalidReuseDetected ||
275288
err instanceof MethodNotAllowedError
276289
) {
290+
const responseCode = 401;
277291
return {
278-
status: 401,
292+
status: responseCode,
279293
message: (err as Error).message,
280294
claims: cat?.claims
281295
};
@@ -322,6 +336,54 @@ export class HttpValidator {
322336
}
323337
}
324338

339+
private async handleCatIf({
340+
cat,
341+
response
342+
}: {
343+
cat: CommonAccessToken;
344+
response?: OutgoingMessage;
345+
}): Promise<number> {
346+
if (!response) {
347+
throw new Error('Missing response object in HTTP validator');
348+
}
349+
let returnCode = 401;
350+
const catif: CatIfDictValue = cat.claims.catif as CatIfDictValue;
351+
if (catif) {
352+
for (const claim in catif) {
353+
if (cat.claims[claim] !== undefined) {
354+
const [code, value, keyid] = catif[claim];
355+
returnCode = code;
356+
for (const header in value) {
357+
if (!Array.isArray(value[header])) {
358+
response.setHeader(header, value[header] as string);
359+
} else {
360+
const newCatDict = value[header][1] as CommonAccessTokenDict;
361+
if (!newCatDict['iss']) {
362+
newCatDict['iss'] = this.opts.issuer;
363+
}
364+
if (!newCatDict['iat']) {
365+
newCatDict['iat'] = Math.floor(Date.now() / 1000);
366+
}
367+
const newCat = CommonAccessTokenFactory.fromDict(newCatDict);
368+
if (!keyid) {
369+
throw new InvalidCatIfError('Missing key id in catif claim');
370+
}
371+
await newCat.mac(
372+
{ kid: keyid, k: this.keys[keyid] },
373+
this.opts.alg || 'HS256',
374+
{ addCwtTag: true }
375+
);
376+
const newToken = newCat.raw?.toString('base64');
377+
const newUrl = new URL(value[header][0] + newToken);
378+
response.setHeader(header, newUrl.toString());
379+
}
380+
}
381+
}
382+
}
383+
}
384+
return returnCode;
385+
}
386+
325387
private async handleReplay({
326388
cat,
327389
count
@@ -358,6 +420,7 @@ export class HttpValidator {
358420
if (!cat.keyId) {
359421
throw new Error('Key ID not found');
360422
}
423+
let responseCode = 200;
361424
if (
362425
cat &&
363426
cat?.shouldRenew &&
@@ -402,9 +465,10 @@ export class HttpValidator {
402465
redirectUrl.searchParams.delete(this.tokenUriParam);
403466
redirectUrl.searchParams.set(this.tokenUriParam, renewedToken);
404467
response.setHeader('Location', redirectUrl.toString());
468+
responseCode = 302;
405469
}
406470
}
407471
}
408-
return { status: 200 };
472+
return { status: responseCode };
409473
}
410474
}

0 commit comments

Comments
 (0)