118 lines
4.0 KiB
TypeScript
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");
|
|
}
|
|
}
|