43 lines
1.6 KiB
TypeScript
43 lines
1.6 KiB
TypeScript
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
|
|
export const TOKEN_LIFETIME_SECONDS = 12 * 60 * 60;
|
|
|
|
export interface TokenService {
|
|
issue(userId: number): string;
|
|
verify(token: string): number | undefined;
|
|
}
|
|
|
|
const b64 = (data: string | Buffer): string => Buffer.from(data).toString('base64url');
|
|
const HEADER = b64(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
|
|
|
export function createTokenService(secret: string, now: () => Date = () => new Date()): TokenService {
|
|
const sign = (data: string): Buffer => createHmac('sha256', secret).update(data).digest();
|
|
|
|
return {
|
|
issue(userId) {
|
|
const iat = Math.floor(now().getTime() / 1000);
|
|
const payload = b64(JSON.stringify({ sub: userId, iat, exp: iat + TOKEN_LIFETIME_SECONDS }));
|
|
return `${HEADER}.${payload}.${b64(sign(`${HEADER}.${payload}`))}`;
|
|
},
|
|
|
|
verify(token) {
|
|
const parts = token.split('.');
|
|
if (parts.length !== 3 || parts[0] !== HEADER) return undefined;
|
|
const [header, payload, signature] = parts as [string, string, string];
|
|
|
|
const expected = sign(`${header}.${payload}`);
|
|
const given = Buffer.from(signature, 'base64url');
|
|
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return undefined;
|
|
|
|
try {
|
|
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { sub?: unknown; exp?: unknown };
|
|
const nowSeconds = Math.floor(now().getTime() / 1000);
|
|
if (!Number.isInteger(claims.sub) || typeof claims.exp !== 'number' || claims.exp <= nowSeconds) return undefined;
|
|
return claims.sub as number;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
},
|
|
};
|
|
}
|