Skip to content

Commit

Permalink
Add new convenience token methods (#392)
Browse files Browse the repository at this point in the history
  • Loading branch information
marcospassos authored May 8, 2024
1 parent 2e54daa commit 20d64f1
Show file tree
Hide file tree
Showing 3 changed files with 256 additions and 27 deletions.
104 changes: 80 additions & 24 deletions src/token/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import {formatCause} from '../error';
import {ApiKey} from '../apiKey';
import {base64UrlDecode, base64UrlEncode} from '../base64Url';

export type Headers = {
export type TokenHeaders = {
typ: string,
alg: string,
kid?: string,
appId?: string,
};

type Claims = {
export type TokenClaims = {
iss: string,
aud: string|string[],
iat: number,
Expand All @@ -20,18 +20,18 @@ type Claims = {
jti?: string,
};

export type TokenPayload = JsonObject & Claims;
export type TokenPayload = JsonObject & TokenClaims;

export class Token {
private static readonly UUID_PATTERN = /^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/;

private readonly headers: Headers;
private readonly headers: TokenHeaders;

private readonly payload: TokenPayload;

private readonly signature: string;

private constructor(headers: Headers, payload: TokenPayload, signature = '') {
private constructor(headers: TokenHeaders, payload: TokenPayload, signature = '') {
this.headers = headers;
this.payload = payload;
this.signature = signature;
Expand Down Expand Up @@ -90,7 +90,7 @@ export class Token {
return Token.of(headers, payload, parts[2]);
}

public static of(headers: Headers, payload: TokenPayload, signature = ''): Token {
public static of(headers: TokenHeaders, payload: TokenPayload, signature = ''): Token {
try {
tokenSchema.validate({
headers: headers,
Expand All @@ -101,12 +101,12 @@ export class Token {
throw new Error(`The token is invalid: ${formatCause(violation)}`);
}

return new Token(headers as Headers, payload as TokenPayload, signature as string);
return new Token(headers as TokenHeaders, payload as TokenPayload, signature as string);
}

public async signedWith(apiKey: ApiKey): Promise<Token> {
const keyId = await apiKey.getIdentifierHash();
const headers: Headers = {
const headers: TokenHeaders = {
...this.headers,
kid: keyId,
alg: apiKey.getSigningAlgorithm(),
Expand Down Expand Up @@ -145,22 +145,7 @@ export class Token {
return this.headers.kid === await apiKey.getIdentifierHash();
}

public withTokenId(tokenId: string): Token {
if (tokenId === '' || !Token.UUID_PATTERN.test(tokenId)) {
throw new Error('The token ID must be a valid UUID.');
}

return new Token(
this.headers,
{
...this.payload,
jti: tokenId,
},
this.signature,
);
}

public getHeaders(): Headers {
public getHeaders(): TokenHeaders {
return {...this.headers};
}

Expand Down Expand Up @@ -212,6 +197,77 @@ export class Token {
return this.payload.iss;
}

public withTokenId(tokenId: string): Token {
if (tokenId === '' || !Token.UUID_PATTERN.test(tokenId)) {
throw new Error('The token ID must be a valid UUID.');
}

return new Token(
this.headers,
{
...this.payload,
jti: tokenId,
},
this.signature,
);
}

public withDuration(duration: number, now: number = Math.floor(Date.now() / 1000)): Token {
return new Token(
this.headers,
{
...this.payload,
iat: now,
exp: now + duration,
},
this.signature,
);
}

public withAddedHeaders(headers: Partial<TokenHeaders>): Token {
return this.withHeaders({
...this.headers,
...Object.fromEntries(
Object.entries(headers)
.filter(([, value]) => value !== undefined),
),
});
}

public withAddedClaims(claims: Partial<TokenClaims>): Token {
return this.withPayload({
...this.payload,
...Object.fromEntries(
Object.entries(claims)
.filter(([, value]) => value !== undefined),
),
});
}

public withHeaders(headers: TokenHeaders): Token {
return new Token(
headers,
this.payload,
this.signature,
);
}

public withPayload(payload: TokenPayload): Token {
return new Token(
this.headers,
payload,
this.signature,
);
}

public withSignature(signature: string): Token {
return new Token(
this.headers,
this.payload,
signature,
);
}

public toJSON(): string {
return this.toString();
}
Expand Down
4 changes: 2 additions & 2 deletions test/schemas/tokenSchema.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {tokenSchema} from '../../src/schema';
import {TokenPayload, Headers} from '../../src/token';
import {TokenPayload, TokenHeaders} from '../../src/token';

type ParsedToken = {
headers: Headers,
headers: TokenHeaders,
payload: TokenPayload,
signature?: string,
};
Expand Down
175 changes: 174 additions & 1 deletion test/token/token.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {webcrypto} from 'crypto';
import {Token, FixedTokenProvider} from '../../src/token';
import {Token, FixedTokenProvider, TokenHeaders, TokenPayload, TokenClaims} from '../../src/token';
import {ApiKey} from '../../src/apiKey';
import {base64UrlEncode} from '../../src/base64Url';

Expand Down Expand Up @@ -543,6 +543,179 @@ describe('A token', () => {
await expect(signedToken.matchesKeyId(apiKey)).resolves.toBeTrue();
});

it('should create a copy of a token with the given headers', () => {
const token = Token.of(
{
typ: 'JWT',
alg: 'none',
},
{
iss: 'croct.io',
aud: 'croct.io',
iat: 1440982923,
},
'signature',
);

const headers = {
typ: 'JWT',
alg: 'other',
appId: '00000000-0000-0000-0000-000000000000',
};

const newToken = token.withHeaders(headers);

expect(newToken.getHeaders()).toEqual(headers);
expect(newToken.getPayload()).toEqual(token.getPayload());
expect(newToken.getSignature()).toBe(token.getSignature());
});

it('should create a copy of a token with the given payload', () => {
const token = Token.of(
{
typ: 'JWT',
alg: 'none',
},
{
iss: 'croct.io',
aud: 'croct.io',
iat: 1440982923,
},
'signature',
);

const payload: TokenPayload = {
iss: 'test.io',
aud: 'test.io',
iat: 1440982923,
sub: 'c4r0l',
};

const newToken = token.withPayload(payload);

expect(newToken.getPayload()).toEqual(payload);
expect(newToken.getHeaders()).toEqual(token.getHeaders());
expect(newToken.getSignature()).toBe(token.getSignature());
});

it('should create a copy of a token with the added claims', () => {
const token = Token.of(
{
typ: 'JWT',
alg: 'none',
},
{
iss: 'croct.io',
aud: 'croct.io',
iat: 1440982923,
},
);

const addedClaims: Partial<TokenClaims> = {
sub: 'c4r0l',
};

const newToken = token.withAddedClaims({
...addedClaims,
// Ensure this prop is removed
aud: undefined,
});

expect(newToken.getPayload()).toStrictEqual({
...token.getPayload(),
...addedClaims,
});
});

it('should create a copy of a token with the added headers', () => {
const token = Token.of(
{
typ: 'JWT',
alg: 'none',
},
{
iss: 'croct.io',
aud: 'croct.io',
iat: 1440982923,
},
);

const addedHeaders: Partial<TokenHeaders> = {
appId: '00000000-0000-0000-0000-000000000000',
};

const newToken = token.withAddedHeaders({
...addedHeaders,
// Ensure this prop is removed
typ: undefined,
});

expect(newToken.getHeaders()).toStrictEqual({
...token.getHeaders(),
...addedHeaders,
});
});

it('should create a copy of a token with a new signature', () => {
const token = Token.of(
{
typ: 'JWT',
alg: 'none',
},
{
iss: 'croct.io',
aud: 'croct.io',
iat: 1440982923,
},
'old-signature',
);

const signature = 'signature';

const newToken = token.withSignature(signature);

expect(newToken.getSignature()).toBe(signature);
expect(newToken.getHeaders()).toEqual(token.getHeaders());
expect(newToken.getPayload()).toEqual(token.getPayload());
});

it('should create a copy of a token with an expiration time for a specific duration', () => {
const now = Math.floor(Date.now() / 1000);
const token = Token.of(
{
typ: 'JWT',
alg: 'none',
},
{
iss: 'croct.io',
aud: 'croct.io',
iat: now,
},
);

const headers = token.getHeaders();
const payload = token.getPayload();

expect(token.getIssueTime()).toBe(now);
expect(token.getExpirationTime()).toBeNull();

const later = now + 3600;
const duration = 100;

const newToken = token.withDuration(duration, later);

expect(newToken.getIssueTime()).toBe(later);
expect(newToken.getExpirationTime()).toBe(later + duration);

expect(newToken.getHeaders()).toEqual(headers);

expect(newToken.getPayload()).toEqual({
...payload,
iat: newToken.getIssueTime(),
exp: newToken.getExpirationTime(),
});
});

it('should be convertible to JSON', () => {
const anonymousToken = Token.parse(anonymousSerializedToken);

Expand Down

0 comments on commit 20d64f1

Please sign in to comment.