Skip to content

Commit

Permalink
Add token expiration (#10)
Browse files Browse the repository at this point in the history
  • Loading branch information
marcospassos authored May 8, 2024
1 parent 2690034 commit e595234
Show file tree
Hide file tree
Showing 9 changed files with 212 additions and 220 deletions.
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"dependencies": {
"@croct/plug": "^0.14.1",
"@croct/plug-react": "^0.7.1",
"@croct/sdk": "^0.16.0",
"@croct/sdk": "^0.16.1",
"cookie": "^0.6.0",
"server-only": "^0.0.1",
"uuid": "^9.0.1"
Expand Down
153 changes: 152 additions & 1 deletion src/config/security.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
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';
const privateKey = 'ES256;MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg3TbbvRM7DNwxY3XGWDmlSRPSfZ9b+ch9TO3jQ6'
+ '8Zyj+hRANCAASmJj/EiEhUaLAWnbXMTb/85WADkuFgoELGZ5ByV7YPlbb2wY6oLjzGkpF6z8iDrvJ4kV6EhaJ4n0HwSQckVLNE';

const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;

describe('getApiKey', () => {
beforeEach(() => {
delete process.env.CROCT_API_KEY;
Expand Down Expand Up @@ -104,4 +114,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(UUID_PATTERN),
});

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());

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
Loading

0 comments on commit e595234

Please sign in to comment.