Files
plp/apps/api/src/auth/auth.config.ts
T
2026-09-15 00:00:06 +08:00

133 lines
4.5 KiB
TypeScript

import {
createCipheriv,
createDecipheriv,
createHmac,
randomBytes,
timingSafeEqual,
} from "node:crypto";
function required(name: string, min = 32): Buffer {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
const decoded =
name === "PHONE_ENCRYPTION_KEY"
? Buffer.from(value, "base64")
: Buffer.from(value);
if (decoded.length < min)
throw new Error(`${name} must be at least ${min} bytes`);
return decoded;
}
const REQUIRED_KEYS = [
"PHONE_ENCRYPTION_KEY",
"PHONE_HMAC_KEY",
"VERIFICATION_CODE_HMAC_KEY",
"REFRESH_TOKEN_HMAC_KEY",
"JWT_SECRET",
] as const;
type AuthSecrets = Record<(typeof REQUIRED_KEYS)[number], Buffer>;
let secrets: AuthSecrets | undefined;
let configuredWebOrigin: string | undefined;
function parseWebOrigin(): string {
const raw = process.env.WEB_ORIGIN;
if (!raw) throw new Error("WEB_ORIGIN is required");
let url: URL;
try {
url = new URL(raw);
} catch {
throw new Error("WEB_ORIGIN must be an absolute HTTP(S) origin");
}
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password ||
url.pathname !== "/" ||
url.search ||
url.hash ||
url.origin !== raw
)
throw new Error(
"WEB_ORIGIN must be one absolute HTTP(S) origin without credentials or path",
);
return url.origin;
}
export function validateAuthEnvironment(): void {
const loaded = Object.fromEntries(
REQUIRED_KEYS.map((name) => [name, required(name)]),
) as unknown as AuthSecrets;
if (loaded.PHONE_ENCRYPTION_KEY.length !== 32)
throw new Error("PHONE_ENCRYPTION_KEY must decode to exactly 32 bytes");
const fingerprints = REQUIRED_KEYS.map((name) =>
loaded[name].toString("hex"),
);
if (new Set(fingerprints).size !== fingerprints.length)
throw new Error("Auth secrets must be independent");
const origin = parseWebOrigin();
if (process.env.DEMO_SMS_CODE_ENABLED === "true" && !demoEnvironmentAllowed())
throw new Error(
"DEMO_SMS_CODE_ENABLED is forbidden outside test or local development",
);
secrets = loaded;
configuredWebOrigin = origin;
}
export function resetAuthEnvironmentForTests(): void {
if (process.env.NODE_ENV !== "test")
throw new Error("Auth environment reset is test-only");
secrets = undefined;
configuredWebOrigin = undefined;
}
function authSecrets(): AuthSecrets {
if (!secrets) validateAuthEnvironment();
return secrets!;
}
export const jwtSecret = (): Buffer => authSecrets().JWT_SECRET;
export const webOrigin = (): string => {
if (!configuredWebOrigin) validateAuthEnvironment();
return configuredWebOrigin!;
};
const demoEnvironmentAllowed = (): boolean =>
process.env.NODE_ENV === "test" ||
(process.env.NODE_ENV === "development" && process.env.APP_ENV === "local");
export const demoSmsCodeEnabled = (): boolean =>
process.env.DEMO_SMS_CODE_ENABLED === "true" && demoEnvironmentAllowed();
export const phoneHmac = (phone: string): string =>
createHmac("sha256", authSecrets().PHONE_HMAC_KEY)
.update(phone)
.digest("hex");
export const codeHmac = (phoneDigest: string, code: string): string =>
createHmac("sha256", authSecrets().VERIFICATION_CODE_HMAC_KEY)
.update(`${phoneDigest}:${code}`)
.digest("hex");
export const refreshHmac = (token: string): string =>
createHmac("sha256", authSecrets().REFRESH_TOKEN_HMAC_KEY)
.update(token)
.digest("hex");
export const safeEqual = (a: string, b: string): boolean => {
const x = Buffer.from(a);
const y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
};
export function encryptPhone(phone: string): Buffer {
const key = authSecrets().PHONE_ENCRYPTION_KEY;
if (key.length !== 32)
throw new Error("PHONE_ENCRYPTION_KEY must decode to exactly 32 bytes");
const nonce = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, nonce);
const body = Buffer.concat([cipher.update(phone, "utf8"), cipher.final()]);
return Buffer.concat([nonce, cipher.getAuthTag(), body]);
}
export function decryptPhone(value: Buffer): string {
const key = authSecrets().PHONE_ENCRYPTION_KEY;
const decipher = createDecipheriv("aes-256-gcm", key, value.subarray(0, 12));
decipher.setAuthTag(value.subarray(12, 28));
return Buffer.concat([
decipher.update(value.subarray(28)),
decipher.final(),
]).toString();
}
export const envInt = (name: string, fallback: number): number => {
const n = Number(process.env[name] ?? fallback);
return Number.isInteger(n) && n > 0 ? n : fallback;
};