Skip to content

Commit

Permalink
Add token expiration
Browse files Browse the repository at this point in the history
  • Loading branch information
marcospassos committed May 8, 2024
1 parent 2690034 commit 89bf131
Show file tree
Hide file tree
Showing 7 changed files with 205 additions and 215 deletions.
151 changes: 150 additions & 1 deletion src/config/security.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import {getApiKey, getAuthenticationKey, isUserTokenAuthenticationEnabled} from './security';
import {ApiKey as MockApiKey} from '@croct/sdk/apiKey';
import {
getApiKey,
getAuthenticationKey,
getTokenDuration,
issueToken,
isUserTokenAuthenticationEnabled,
} from './security';
import {getAppId} from '@/config/appId';

describe('security', () => {
const identifier = '00000000-0000-0000-0000-000000000000';
Expand Down Expand Up @@ -104,4 +112,145 @@ describe('security', () => {
expect(isUserTokenAuthenticationEnabled()).toBe(false);
});
});

describe('getTokenDuration', () => {
beforeEach(() => {
delete process.env.CROCT_TOKEN_DURATION;
});

it('should return the default duration if not set', () => {
expect(getTokenDuration()).toBe(24 * 60 * 60);
});

it('should return the duration if set', () => {
process.env.CROCT_TOKEN_DURATION = '3600';

expect(getTokenDuration()).toBe(3600);
});

it('should throw an error if the duration is not a number', () => {
process.env.CROCT_TOKEN_DURATION = 'invalid';

expect(() => getTokenDuration()).toThrow('The token duration must be a positive integer.');
});

it('should throw an error if the duration is not a positive number', () => {
process.env.CROCT_TOKEN_DURATION = '-1';

expect(() => getTokenDuration()).toThrow('The token duration must be a positive integer.');
});
});

describe('issueToken', () => {
beforeEach(() => {
delete process.env.NEXT_PUBLIC_CROCT_APP_ID;
delete process.env.CROCT_API_KEY;
delete process.env.CROCT_DISABLE_USER_TOKEN_AUTHENTICATION;
delete process.env.CROCT_TOKEN_DURATION;
});

afterEach(() => {
jest.useRealTimers();
});

it.each<[string, string|undefined]>([
['an anonymous user', undefined],
['an identified user', 'user-id'],
])('should return a signed token for %s', async (_, userId) => {
jest.useFakeTimers({now: Date.now()});

const keyPair = await crypto.subtle.generateKey(
{
name: 'ECDSA',
namedCurve: 'P-256',
},
true,
['sign', 'verify'],
);

const localPrivateKey = Buffer.from(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey))
.toString('base64');

const apiKey = MockApiKey.of('00000000-0000-0000-0000-000000000001', `ES256;${localPrivateKey}`);

process.env.NEXT_PUBLIC_CROCT_APP_ID = '00000000-0000-0000-0000-000000000000';
process.env.CROCT_API_KEY = apiKey.export();
process.env.CROCT_TOKEN_DURATION = '3600';
process.env.CROCT_DISABLE_USER_TOKEN_AUTHENTICATION = 'false';

expect(getAppId()).toBe(process.env.NEXT_PUBLIC_CROCT_APP_ID);
expect(getAuthenticationKey().export()).toBe(apiKey.export());
expect(isUserTokenAuthenticationEnabled()).toBe(true);
expect(getTokenDuration()).toBe(3600);

const token = await issueToken(userId);

expect(token.getHeaders()).toEqual({
alg: 'ES256',
typ: 'JWT',
appId: process.env.NEXT_PUBLIC_CROCT_APP_ID,
kid: await apiKey.getIdentifierHash(),
});

expect(token.getPayload()).toEqual({
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
iss: 'croct.io',
aud: 'croct.io',
sub: userId,
jti: expect.stringMatching(/^[a-f0-9-]{36}$/),
});

const [header, payload, signature] = token.toString().split('.');

const verification = crypto.subtle.verify(
{
name: 'ECDSA',
hash: {
name: 'SHA-256',
},
},
keyPair.publicKey,
Buffer.from(signature, 'base64url'),
Buffer.from(`${header}.${payload}`),
);

await expect(verification).resolves.toBeTrue();
});

it.each<[string, string|undefined]>([
['an anonymous user', undefined],
['an identified user', 'user-id'],
])('should return a unsigned token for %s', async (_, userId) => {
jest.useFakeTimers({now: Date.now()});

process.env.NEXT_PUBLIC_CROCT_APP_ID = '00000000-0000-0000-0000-000000000000';
process.env.CROCT_API_KEY = `${identifier}:${privateKey}`;
process.env.CROCT_DISABLE_USER_TOKEN_AUTHENTICATION = 'true';
process.env.CROCT_TOKEN_DURATION = '3600';

expect(getAppId()).toBe(process.env.NEXT_PUBLIC_CROCT_APP_ID);
expect(getAuthenticationKey().export()).toBe(process.env.CROCT_API_KEY);
expect(isUserTokenAuthenticationEnabled()).toBe(false);
expect(getTokenDuration()).toBe(3600);

const token = await issueToken(userId);

expect(token.getSignature()).toBe('');

expect(token.getHeaders()).toEqual({
alg: 'none',
typ: 'JWT',
appId: process.env.NEXT_PUBLIC_CROCT_APP_ID,
});

expect(token.getPayload()).toEqual({
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
iss: 'croct.io',
aud: 'croct.io',
sub: userId,
});
});
});
});
31 changes: 31 additions & 0 deletions src/config/security.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import {ApiKey} from '@croct/sdk/apiKey';
import {Token} from '@croct/sdk/token';
import {v4 as uuid} from 'uuid';
import {getAppId} from '@/config/appId';

export function getApiKey(): ApiKey {
const apiKey = process.env.CROCT_API_KEY;
Expand Down Expand Up @@ -32,3 +35,31 @@ export function isUserTokenAuthenticationEnabled(): boolean {
return process.env.CROCT_API_KEY !== undefined
&& process.env.CROCT_DISABLE_USER_TOKEN_AUTHENTICATION !== 'true';
}

export function getTokenDuration(): number {
const duration = process.env.CROCT_TOKEN_DURATION;

if (duration === undefined) {
return 24 * 60 * 60;
}

const parsedDuration = Number.parseInt(duration, 10);

if (Number.isNaN(parsedDuration) || parsedDuration <= 0) {
throw new Error('The token duration must be a positive integer.');
}

return parsedDuration;
}

export function issueToken(userId: string|null = null): Promise<Token> {
const token = Token.issue(getAppId(), userId)
.withDuration(getTokenDuration());

Check failure on line 57 in src/config/security.ts

View workflow job for this annotation

GitHub Actions / validate

Property 'withDuration' does not exist on type 'Token'.

if (isUserTokenAuthenticationEnabled()) {
return token.withTokenId(uuid())
.signedWith(getAuthenticationKey());
}

return Promise.resolve(token);
}
14 changes: 4 additions & 10 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {NextRequest, NextMiddleware, NextResponse} from 'next/server';
import cookie from 'cookie';
import {v4 as uuid, v4 as uuidv4} from 'uuid';
import {v4 as uuidv4} from 'uuid';
import {Token} from '@croct/sdk/token';
import {Header, QueryParameter} from '@/config/http';
import {
Expand All @@ -9,8 +9,7 @@ import {
getPreviewCookieOptions,
getUserTokenCookieOptions,
} from '@/config/cookie';
import {getAppId} from '@/config/appId';
import {getAuthenticationKey, isUserTokenAuthenticationEnabled} from './config/security';
import {getAuthenticationKey, issueToken, isUserTokenAuthenticationEnabled} from './config/security';

// Ignore static assets
export const config = {
Expand Down Expand Up @@ -140,20 +139,15 @@ async function getUserToken(
}

const userId = userIdResolver !== undefined ? await userIdResolver(request) : undefined;
const authenticated = isUserTokenAuthenticationEnabled();

if (
token === null
|| (authenticated && !token.isSigned())
|| (isUserTokenAuthenticationEnabled() && !token.isSigned())
|| !token.isValidNow()
|| (userId !== undefined && (userId === null ? !token.isAnonymous() : !token.isSubject(userId)))
|| (token.isSigned() && !await token.matchesKeyId(getAuthenticationKey()))
) {
return authenticated
? Token.issue(getAppId(), userId)
.withTokenId(uuid())
.signedWith(getAuthenticationKey())
: Token.issue(getAppId(), userId);
return issueToken(userId);
}

return token;
Expand Down
98 changes: 7 additions & 91 deletions src/server/anonymize.test.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,14 @@
import {ApiKey as MockApiKey} from '@croct/sdk/apiKey';
import {cookies} from 'next/headers';
import {Token} from '@croct/sdk/token';
import {getAppId} from '@/config/appId';
import {getAuthenticationKey, isUserTokenAuthenticationEnabled} from '@/config/security';
import {issueToken} from '@/config/security';
import {anonymize} from '@/server/anonymize';

jest.mock(
'uuid',
() => ({
v4: jest.fn(() => '00000000-0000-0000-0000-000000000000'),
}),
);

jest.mock(
'@/config/security',
() => {
const identifier = '00000000-0000-0000-0000-000000000000';
const apiKey = MockApiKey.of(identifier);

return {
__esModule: true,
...jest.requireActual('@/config/security'),
getApiKey: jest.fn(() => apiKey),
getAuthenticationKey: jest.fn(() => apiKey),
isUserTokenAuthenticationEnabled: jest.fn(() => false),
};
},
);

jest.mock(
'@/config/appId',
() => ({
__esModule: true,
...jest.requireActual('@/config/appId'),
getAppId: jest.fn(() => '00000000-0000-0000-0000-000000000000'),
...jest.requireActual('@/config/security'),
issueToken: jest.fn(),
}),
);

Expand Down Expand Up @@ -73,16 +48,12 @@ jest.mock(
describe('anonymize', () => {
afterEach(() => {
jest.mocked(cookies().set).mockClear();
jest.mocked(isUserTokenAuthenticationEnabled).mockReset();
jest.useRealTimers();
});

it('should set a unsigned token in the cookie', async () => {
jest.useFakeTimers({now: Date.now()});

jest.mocked(isUserTokenAuthenticationEnabled).mockReturnValue(false);
it('should set a token in the cookie', async () => {
const token = Token.issue('00000000-0000-0000-0000-000000000001');

expect(isUserTokenAuthenticationEnabled()).toBe(false);
jest.mocked(issueToken).mockResolvedValue(token);

await expect(anonymize()).resolves.toBeUndefined();

Expand All @@ -95,62 +66,7 @@ describe('anonymize', () => {
domain: undefined,
secure: false,
sameSite: 'lax',
value: Token.issue(getAppId()).toString(),
value: token.toString(),
});
});

it('should set a signed token in the cookie', async () => {
jest.useFakeTimers({now: Date.now()});

const keyPair = await crypto.subtle.generateKey(
{
name: 'ECDSA',
namedCurve: 'P-256',
},
true,
['sign', 'verify'],
);

const exportedKey = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
const privateKey = Buffer.from(exportedKey).toString('base64');

const apiKey = MockApiKey.of('00000000-0000-0000-0000-000000000001', `ES256;${privateKey}`);

jest.mocked(getAuthenticationKey).mockReturnValue(apiKey);

jest.mocked(isUserTokenAuthenticationEnabled).mockReturnValue(true);

expect(isUserTokenAuthenticationEnabled()).toBe(true);

await expect(anonymize()).resolves.toBeUndefined();

const jar = cookies();

expect(jar.set).toHaveBeenCalledWith({
name: 'croct',
maxAge: 86400,
path: '/',
domain: undefined,
secure: false,
sameSite: 'lax',
value: expect.toBeString(),
});

const {value: cookieValue} = jest.mocked(jar.set).mock.calls[0][0] as {value: string};
const [header, payload, signature] = cookieValue.split('.');

const verification = crypto.subtle.verify(
{
name: 'ECDSA',
hash: {
name: 'SHA-256',
},
},
keyPair.publicKey,
Buffer.from(signature, 'base64url'),
Buffer.from(`${header}.${payload}`),
);

await expect(verification).resolves.toBeTrue();
});
});
12 changes: 2 additions & 10 deletions src/server/anonymize.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,9 @@
import {cookies} from 'next/headers';
import {Token} from '@croct/sdk/token';
import {v4 as uuid} from 'uuid';
import {getUserTokenCookieOptions} from '@/config/cookie';
import {getAuthenticationKey, isUserTokenAuthenticationEnabled} from '@/config/security';
import {getAppId} from '@/config/appId';
import {issueToken} from '@/config/security';

export async function anonymize(): Promise<void> {
const token = isUserTokenAuthenticationEnabled()
? await Token.issue(getAppId())
.withTokenId(uuid())
.signedWith(getAuthenticationKey())
: Token.issue(getAppId());

const token = await issueToken();
const jar = cookies();
const cookieOptions = getUserTokenCookieOptions();

Expand Down
Loading

0 comments on commit 89bf131

Please sign in to comment.