import { HttpStatus, Injectable } from "@nestjs/common"; import { ErrorCode } from "@drift/contracts"; import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { DomainException } from "../common/domain.exception.js"; import { envInt, jwtSecret } from "./auth.config.js"; export interface AccessClaims { sub: string; session_id: string; device_id: string; scopes: string[]; token_version: number; iss: string; aud: string; exp: number; iat: number; } const b64 = (v: Buffer | string) => Buffer.from(v).toString("base64url"); const BASE64URL = /^[A-Za-z0-9_-]+$/; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const DEVICE_ID = /^[A-Za-z0-9_-]{8,128}$/; const decodeCanonical = (value: string): Buffer => { if (!BASE64URL.test(value)) throw new Error(); const decoded = Buffer.from(value, "base64url"); if (decoded.toString("base64url") !== value) throw new Error(); return decoded; }; const plainObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype; @Injectable() export class TokenService { issueAccess( input: Omit, ): string { const now = Math.floor(Date.now() / 1000); const header = b64(JSON.stringify({ alg: "HS256", typ: "JWT" })); const body = b64( JSON.stringify({ ...input, iss: process.env.JWT_ISSUER ?? "drift-api", aud: process.env.JWT_AUDIENCE ?? "drift-web", iat: now, exp: now + envInt("ACCESS_TOKEN_TTL_SECONDS", 900), }), ); const data = `${header}.${body}`; return `${data}.${createHmac("sha256", jwtSecret()).update(data).digest("base64url")}`; } verifyAccess(token: string): AccessClaims { try { const parts = token.split("."); if (parts.length !== 3) throw new Error(); const [h, p, s] = parts as [string, string, string]; const header: unknown = JSON.parse(decodeCanonical(h).toString()); if ( !plainObject(header) || header.alg !== "HS256" || header.typ !== "JWT" || Object.keys(header).length !== 2 ) throw new Error(); const expected = createHmac("sha256", jwtSecret()) .update(`${h}.${p}`) .digest(); const actual = decodeCanonical(s); if ( expected.length !== actual.length || !timingSafeEqual(expected, actual) ) throw new Error(); const decodedClaims: unknown = JSON.parse(decodeCanonical(p).toString()); if (!plainObject(decodedClaims)) throw new Error(); const claims = decodedClaims as unknown as AccessClaims; const now = Math.floor(Date.now() / 1000); if ( Object.keys(claims).length !== 9 || !UUID.test(claims.sub) || !UUID.test(claims.session_id) || !DEVICE_ID.test(claims.device_id) || !Array.isArray(claims.scopes) || claims.scopes.length !== 1 || claims.scopes[0] !== "user" || !Number.isInteger(claims.token_version) || claims.token_version < 0 || !Number.isFinite(claims.iat) || !Number.isInteger(claims.iat) || !Number.isFinite(claims.exp) || !Number.isInteger(claims.exp) || claims.iat > now + 300 || claims.exp <= claims.iat || claims.iss !== (process.env.JWT_ISSUER ?? "drift-api") || claims.aud !== (process.env.JWT_AUDIENCE ?? "drift-web") ) throw new Error(); if (claims.exp <= now) throw new DomainException( ErrorCode.AUTH_TOKEN_EXPIRED, "Access token expired", HttpStatus.UNAUTHORIZED, ); return claims; } catch (error) { if (error instanceof DomainException) throw error; throw new DomainException( ErrorCode.AUTH_UNAUTHORIZED, "Unauthorized", HttpStatus.UNAUTHORIZED, ); } } newRefresh(): string { return randomBytes(32).toString("base64url"); } }