diff --git a/src/config/security.test.ts b/src/config/security.test.ts index 6dbccfd..654448f 100644 --- a/src/config/security.test.ts +++ b/src/config/security.test.ts @@ -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'; @@ -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, + }); + }); + }); }); diff --git a/src/config/security.ts b/src/config/security.ts index d1d5dcb..151bf87 100644 --- a/src/config/security.ts +++ b/src/config/security.ts @@ -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; @@ -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 { + const token = Token.issue(getAppId(), userId) + .withDuration(getTokenDuration()); + + if (isUserTokenAuthenticationEnabled()) { + return token.withTokenId(uuid()) + .signedWith(getAuthenticationKey()); + } + + return Promise.resolve(token); +} diff --git a/src/middleware.ts b/src/middleware.ts index 22fe97e..e6dcbdf 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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 { @@ -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 = { @@ -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; diff --git a/src/server/anonymize.test.ts b/src/server/anonymize.test.ts index 7c6c60f..8e21fde 100644 --- a/src/server/anonymize.test.ts +++ b/src/server/anonymize.test.ts @@ -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(), }), ); @@ -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(); @@ -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(); - }); }); diff --git a/src/server/anonymize.ts b/src/server/anonymize.ts index a0ac501..bcf8aa1 100644 --- a/src/server/anonymize.ts +++ b/src/server/anonymize.ts @@ -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 { - const token = isUserTokenAuthenticationEnabled() - ? await Token.issue(getAppId()) - .withTokenId(uuid()) - .signedWith(getAuthenticationKey()) - : Token.issue(getAppId()); - + const token = await issueToken(); const jar = cookies(); const cookieOptions = getUserTokenCookieOptions(); diff --git a/src/server/identify.test.ts b/src/server/identify.test.ts index 6855fd2..9b1b8ed 100644 --- a/src/server/identify.test.ts +++ b/src/server/identify.test.ts @@ -1,39 +1,14 @@ -import {ApiKey as MockApiKey} from '@croct/sdk/apiKey'; import {cookies} from 'next/headers'; import {Token} from '@croct/sdk/token'; +import {issueToken} from '@/config/security'; import {identify} from '@/server/identify'; -import {getAppId} from '@/config/appId'; -import {getAuthenticationKey, isUserTokenAuthenticationEnabled} from '@/config/security'; - -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(), }), ); @@ -73,59 +48,17 @@ jest.mock( describe('identify', () => { 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()}); - - const userId = 'test'; + it('should set a token in the cookie', async () => { + const userId = 'user-id'; + const token = Token.issue('00000000-0000-0000-0000-000000000001', userId); - jest.mocked(isUserTokenAuthenticationEnabled).mockReturnValue(false); - - expect(isUserTokenAuthenticationEnabled()).toBe(false); + jest.mocked(issueToken).mockResolvedValue(token); await expect(identify(userId)).resolves.toBeUndefined(); - const jar = cookies(); - - expect(jar.set).toHaveBeenCalledWith({ - name: 'croct', - maxAge: 86400, - path: '/', - domain: undefined, - secure: false, - sameSite: 'lax', - value: Token.issue(getAppId(), userId).toString(), - }); - }); - - it('should set a signed token in the cookie', async () => { - jest.useFakeTimers({now: Date.now()}); - const userId = 'test'; - - 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(identify(userId)).resolves.toBeUndefined(); + expect(issueToken).toHaveBeenCalledWith(userId); const jar = cookies(); @@ -136,24 +69,7 @@ describe('identify', () => { domain: undefined, secure: false, sameSite: 'lax', - value: expect.toBeString(), + value: token.toString(), }); - - 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(); }); }); diff --git a/src/server/identify.ts b/src/server/identify.ts index 0cff729..857ce84 100644 --- a/src/server/identify.ts +++ b/src/server/identify.ts @@ -1,17 +1,9 @@ import {cookies} from 'next/headers'; -import {Token} from '@croct/sdk/token'; -import {v4 as uuid} from 'uuid'; -import {getAppId} from '@/config/appId'; -import {getAuthenticationKey, isUserTokenAuthenticationEnabled} from '@/config/security'; +import {issueToken} from '@/config/security'; import {getUserTokenCookieOptions} from '@/config/cookie'; export async function identify(userId: string): Promise { - const token = isUserTokenAuthenticationEnabled() - ? await Token.issue(getAppId(), userId) - .withTokenId(uuid()) - .signedWith(getAuthenticationKey()) - : Token.issue(getAppId(), userId); - + const token = await issueToken(userId); const jar = cookies(); const cookieOptions = getUserTokenCookieOptions();