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
18 changes: 18 additions & 0 deletions src/config/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export interface AuthConfig {
refreshSkewMs: number;
}

export interface JWTConfig {
/** Clock-skew tolerance in milliseconds for `exp`/`nbf` validation. */
clockSkewMs: number;
}

/**
* Resolves the authentication token-lifecycle configuration, allowing the
* refresh endpoint and skew to be overridden per environment via
Expand All @@ -27,3 +32,16 @@ export const getAuthConfig = (): AuthConfig => {
refreshSkewMs: Number.isFinite(skew) && skew >= 0 ? skew : 60_000,
};
};

/**
* Resolves JWT validation tolerance. Allows server-side clocks to differ slightly
* without rejecting otherwise valid tokens.
*/
export const getJWTConfig = (): JWTConfig => {
const skewRaw = process.env.JWT_CLOCK_SKEW_MS;
const skew = skewRaw ? Number.parseInt(skewRaw, 10) : NaN;

return {
clockSkewMs: Number.isFinite(skew) && skew >= 0 ? skew : 5_000,
};
};
88 changes: 88 additions & 0 deletions src/lib/auth/__tests__/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { verifyToken, verifyTokenDetailed } from '../jwt';

const SECRET = 'test-jwt-secret';
const USER_ROLE = 'STUDENT' as const;

function base64url(value: string): string {
return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}

async function signTokenWithSecret(payload: Record<string, unknown>): Promise<string> {
const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const body = base64url(JSON.stringify(payload));
const unsigned = `${header}.${body}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(unsigned));
const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
return `${unsigned}.${signatureB64}`;
}

describe('JWT clock skew tolerance', () => {
beforeEach(() => {
process.env.JWT_SECRET = SECRET;
process.env.JWT_CLOCK_SKEW_MS = '5000';
});

afterEach(() => {
delete process.env.JWT_SECRET;
delete process.env.JWT_CLOCK_SKEW_MS;
});

it('accepts exp and nbf timestamps within the configured leeway', async () => {
const nowSeconds = Math.floor(Date.now() / 1000);

const expiredSoon = await signTokenWithSecret({
sub: 'user-1',
role: USER_ROLE,
iat: nowSeconds - 10,
exp: nowSeconds - 2,
});

const notYetValid = await signTokenWithSecret({
sub: 'user-2',
role: USER_ROLE,
iat: nowSeconds - 10,
nbf: nowSeconds + 2,
});

await expect(verifyToken(expiredSoon)).resolves.not.toBeNull();
await expect(verifyToken(notYetValid)).resolves.not.toBeNull();

await expect(verifyTokenDetailed(expiredSoon)).resolves.toMatchObject({ valid: true });
await expect(verifyTokenDetailed(notYetValid)).resolves.toMatchObject({ valid: true });
});

it('rejects tokens beyond the allowed skew window', async () => {
const nowSeconds = Math.floor(Date.now() / 1000);

const expiredFarPast = await signTokenWithSecret({
sub: 'user-3',
role: USER_ROLE,
iat: nowSeconds - 60,
exp: nowSeconds - 10,
});

const notYetValidFarFuture = await signTokenWithSecret({
sub: 'user-4',
role: USER_ROLE,
iat: nowSeconds - 60,
nbf: nowSeconds + 10,
});

await expect(verifyToken(expiredFarPast)).resolves.toBeNull();
await expect(verifyToken(notYetValidFarFuture)).resolves.toBeNull();

await expect(verifyTokenDetailed(expiredFarPast)).resolves.toMatchObject({ valid: false, reason: 'expired' });
await expect(verifyTokenDetailed(notYetValidFarFuture)).resolves.toMatchObject({ valid: false, reason: 'not_yet_valid' });
});
});
13 changes: 10 additions & 3 deletions src/lib/auth/jwt.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { SignJWT } from 'jose';
import { getJWTConfig } from '@/config/environment';
import { UserRole } from '@/types/api';

export interface JWTPayload {
Expand Down Expand Up @@ -32,6 +33,8 @@ const getSecret = () => {
return new TextEncoder().encode(secret);
};

const getClockSkewSeconds = (): number => getJWTConfig().clockSkewMs / 1000;

/**
* Signs a new JWT for the given payload. Uses the `jose` library (Node runtime).
*/
Expand Down Expand Up @@ -85,7 +88,10 @@ export async function verifyToken(token: string | undefined | null): Promise<JWT
const payloadJson = new TextDecoder().decode(base64UrlDecode(payloadB64));
const payload = JSON.parse(payloadJson) as JWTPayload;

if (payload.exp && Date.now() / 1000 > payload.exp) return null;
const nowSeconds = Date.now() / 1000;
const clockSkewSeconds = getClockSkewSeconds();
if (payload.exp && nowSeconds > payload.exp + clockSkewSeconds) return null;
if (payload.nbf && nowSeconds < payload.nbf - clockSkewSeconds) return null;

const validRoles: UserRole[] = [
UserRole.ADMIN,
Expand Down Expand Up @@ -160,10 +166,11 @@ export async function verifyTokenDetailed(
const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64))) as JWTPayload;

const nowSeconds = Date.now() / 1000;
if (payload.exp && nowSeconds > payload.exp) {
const clockSkewSeconds = getClockSkewSeconds();
if (payload.exp && nowSeconds > payload.exp + clockSkewSeconds) {
return { valid: false, payload, reason: 'expired' };
}
if (payload.nbf && nowSeconds < payload.nbf) {
if (payload.nbf && nowSeconds < payload.nbf - clockSkewSeconds) {
return { valid: false, payload, reason: 'not_yet_valid' };
}

Expand Down
Loading