diff --git a/src/config/environment.ts b/src/config/environment.ts index 12059445..3abd6330 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -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 @@ -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, + }; +}; diff --git a/src/lib/auth/__tests__/jwt.test.ts b/src/lib/auth/__tests__/jwt.test.ts new file mode 100644 index 00000000..c7a6c1f5 --- /dev/null +++ b/src/lib/auth/__tests__/jwt.test.ts @@ -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): Promise { + 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' }); + }); +}); diff --git a/src/lib/auth/jwt.ts b/src/lib/auth/jwt.ts index 705d3919..e3fa5ec6 100644 --- a/src/lib/auth/jwt.ts +++ b/src/lib/auth/jwt.ts @@ -1,4 +1,5 @@ import { SignJWT } from 'jose'; +import { getJWTConfig } from '@/config/environment'; import { UserRole } from '@/types/api'; export interface JWTPayload { @@ -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). */ @@ -85,7 +88,10 @@ export async function verifyToken(token: string | undefined | null): Promise 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, @@ -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' }; }