Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ FRONTEND_URL=http://localhost:5173

# Docker Postgres defaults
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/accesslayer
APP_SECRET=your_32_character_long_secret_string_here

GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
Expand Down
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export const envSchema = z.object({
DATABASE_URL: z
.string()
.min(1, 'DATABASE_URL is required in the environment variables'),
APP_SECRET: z
.string()
.min(32, 'APP_SECRET should be at least 32 characters')
.default('accesslayer_default_development_secret_key_32_bytes_long'),

GMAIL_USER: z.string(),
GMAIL_APP_PASSWORD: z.string(),
Expand Down
96 changes: 96 additions & 0 deletions src/utils/cursor.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import crypto from 'crypto';
import { Buffer } from 'node:buffer';
import { envConfig } from '../config';

/**
* Custom error class thrown when cursor validation fails.
*/
export class CursorChecksumError extends Error {
constructor(message: string = 'Invalid or tampered cursor checksum') {
super(message);
this.name = 'CursorChecksumError';
}
}

/**
* Generates an HMAC SHA-256 checksum for the given string payload.
*
* @param payload - The data string to checksum
* @returns A hex string representing the checksum
*/
export function generateCursorChecksum(payload: string): string {
const secret = envConfig.APP_SECRET;
return crypto.createHmac('sha256', secret).update(payload).digest('hex');
}

/**
* Encodes an object payload into a base64url string with an appended HMAC checksum.
* Format: `base64url(payload).checksum`
*
* @param payload - The object or string to encode into a cursor
* @returns The signed cursor string
*/
export function encodeCursor<T>(payload: T): string {
const payloadStr = JSON.stringify(payload);
const base64Payload = Buffer.from(payloadStr).toString('base64url');
const checksum = generateCursorChecksum(base64Payload);

return `${base64Payload}.${checksum}`;
}

/**
* Decodes a cursor string, verifying its integrity via the appended checksum.
*
* @param cursor - The signed cursor string (e.g. `base64url.checksum`)
* @returns The decoded payload object
* @throws {CursorChecksumError} If the checksum is missing or invalid
*/
export function decodeCursor<T>(cursor: string): T {
if (!cursor || typeof cursor !== 'string') {
throw new CursorChecksumError('Cursor must be a provided string');
}

const parts = cursor.split('.');
if (parts.length !== 2) {
throw new CursorChecksumError('Invalid cursor format. Expected base64payload.checksum');
}

const [base64Payload, providedChecksum] = parts;

if (!base64Payload || !providedChecksum) {
throw new CursorChecksumError('Cursor payload or checksum cannot be empty');
}

const expectedChecksum = generateCursorChecksum(base64Payload);

// Use timing-safe equal to prevent timing attacks comparing checksums
let expectedBuffer: Buffer;
let providedBuffer: Buffer;

try {
expectedBuffer = Buffer.from(expectedChecksum, 'hex');
providedBuffer = Buffer.from(providedChecksum, 'hex');
} catch {
throw new CursorChecksumError('Invalid checksum format');
}

if (
expectedBuffer.length !== providedBuffer.length ||
!crypto.timingSafeEqual(expectedBuffer, providedBuffer)
) {
throw new CursorChecksumError('Cursor checksum mismatch');
}

let payloadStr: string;
try {
payloadStr = Buffer.from(base64Payload, 'base64url').toString('utf8');
} catch {
throw new CursorChecksumError('Failed to decode base64 payload');
}

try {
return JSON.parse(payloadStr) as T;
} catch {
throw new CursorChecksumError('Failed to parse cursor JSON payload');
}
}
101 changes: 101 additions & 0 deletions src/utils/test/cursor.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
jest.mock('../../config', () => ({
envConfig: {
APP_SECRET: 'test_secret_for_hmac_123456789012',
},
}));

import {
encodeCursor,
decodeCursor,
generateCursorChecksum,
CursorChecksumError,
} from '../cursor.utils';

describe('Cursor Utils', () => {
const samplePayload = { id: 'user_123', createdAt: '2023-01-01T00:00:00.000Z' };

describe('generateCursorChecksum', () => {
it('should return a deterministic 64-character hex string for the same input', () => {
const checksum1 = generateCursorChecksum('test_payload');
const checksum2 = generateCursorChecksum('test_payload');

expect(checksum1).toBe(checksum2);
expect(checksum1).toHaveLength(64);
expect(/^[0-9a-f]{64}$/.test(checksum1)).toBe(true);
});

it('should return different checksums for different inputs', () => {
const checksum1 = generateCursorChecksum('test_payload_1');
const checksum2 = generateCursorChecksum('test_payload_2');

expect(checksum1).not.toBe(checksum2);
});
});

describe('encodeCursor', () => {
it('should generate a cursor containing exactly one dot delimiter', () => {
const cursor = encodeCursor(samplePayload);
expect(cursor).toContain('.');
expect(cursor.split('.')).toHaveLength(2);
});

it('should generate consistent cursors for the same payload object', () => {
const cursor1 = encodeCursor(samplePayload);
const cursor2 = encodeCursor(samplePayload);
expect(cursor1).toBe(cursor2);
});
});

describe('decodeCursor', () => {
it('should correctly decode a valid cursor', () => {
const cursor = encodeCursor(samplePayload);
const decoded = decodeCursor<typeof samplePayload>(cursor);
expect(decoded).toEqual(samplePayload);
});

it('should throw CursorChecksumError for invalid formats', () => {
expect(() => decodeCursor('not_a_valid_cursor')).toThrow(CursorChecksumError);
expect(() => decodeCursor('foo.bar.baz')).toThrow(CursorChecksumError);
});

it('should throw CursorChecksumError when checksum is tampered', () => {
const cursor = encodeCursor(samplePayload);
const [payload, checksum] = cursor.split('.');
const tamperedChecksum = checksum.substring(0, 63) + (checksum.endsWith('a') ? 'b' : 'a');
const tamperedCursor = `${payload}.${tamperedChecksum}`;

expect(() => decodeCursor(tamperedCursor)).toThrow(CursorChecksumError);
});

it('should throw CursorChecksumError when payload is tampered', () => {
const cursor = encodeCursor(samplePayload);
const [payload, checksum] = cursor.split('.');
// Change base64 by modifying a character
const tamperedPayload = payload.substring(0, payload.length - 1) + (payload.endsWith('a') ? 'b' : 'a');
const tamperedCursor = `${tamperedPayload}.${checksum}`;

expect(() => decodeCursor(tamperedCursor)).toThrow(CursorChecksumError);
});

it('should throw CursorChecksumError for empty string inputs', () => {
expect(() => decodeCursor('')).toThrow(CursorChecksumError);
expect(() => decodeCursor('.')).toThrow(CursorChecksumError);
expect(() => decodeCursor('payload.')).toThrow(CursorChecksumError);
expect(() => decodeCursor('.checksum')).toThrow(CursorChecksumError);
});

it('should throw CursorChecksumError for invalid target typed primitives', () => {

expect(() => decodeCursor(123 as any)).toThrow(CursorChecksumError);
});

it('should throw CursorChecksumError for malformed JSON payload', () => {
const badJsonStr = 'bad_json_string';
const payload = Buffer.from(badJsonStr).toString('base64url');
const checksum = generateCursorChecksum(payload);
const cursor = `${payload}.${checksum}`;

expect(() => decodeCursor(cursor)).toThrow(CursorChecksumError);
});
});
});
Loading