Files
plp/apps/api/src/auth/token.service.ts
T
2026-09-14 20:05:40 +08:00

118 lines
4.0 KiB
TypeScript

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<string, unknown> =>
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype;
@Injectable()
export class TokenService {
issueAccess(
input: Omit<AccessClaims, "iss" | "aud" | "exp" | "iat">,
): 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");
}
}