feat: 实现演示验证码和令牌轮换

This commit is contained in:
root
2026-09-14 18:43:45 +08:00
parent 6b901e074e
commit 3e04197b02
24 changed files with 1486 additions and 12 deletions
+13
View File
@@ -1,3 +1,16 @@
# Copy this file to .env and replace test-only values for non-test environments.
DATABASE_URL=postgresql://drift:drift_test_only@127.0.0.1:55432/drift_bottle_test?schema=public
REDIS_URL=redis://127.0.0.1:56379
WEB_ORIGIN=http://localhost:3000
PHONE_ENCRYPTION_KEY=<base64-encoded-exactly-32-byte-key>
PHONE_HMAC_KEY=<independent-at-least-32-byte-secret>
VERIFICATION_CODE_HMAC_KEY=<independent-at-least-32-byte-secret>
REFRESH_TOKEN_HMAC_KEY=<independent-at-least-32-byte-secret>
JWT_SECRET=<at-least-32-byte-secret>
JWT_ISSUER=drift-api
JWT_AUDIENCE=drift-web
ACCESS_TOKEN_TTL_SECONDS=900
REFRESH_TOKEN_TTL_SECONDS=2592000
SMS_CODE_TTL_SECONDS=300
REDIS_KEY_PREFIX=drift:auth:
TRUST_PROXY=false
+1
View File
@@ -3,3 +3,4 @@ dist
coverage
docs/
pnpm-lock.yaml
+2 -1
View File
@@ -7,7 +7,7 @@
"build": "tsc -p tsconfig.build.json",
"start": "node dist/main.js",
"test": "vitest run --config vitest.config.ts --no-file-parallelism",
"test:e2e": "vitest run --config vitest.config.ts src/health/health.e2e-spec.ts --no-file-parallelism",
"test:e2e": "vitest run --config vitest.config.ts src/health/health.e2e-spec.ts src/auth/auth.e2e-spec.ts --no-file-parallelism",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
@@ -18,6 +18,7 @@
"@prisma/client": "6.19.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"cookie": "1.1.1",
"helmet": "^8.1.0",
"redis": "^5.8.2",
"reflect-metadata": "^0.2.2",
+2 -1
View File
@@ -1,8 +1,9 @@
import { MiddlewareConsumer, Module, type NestModule } from "@nestjs/common";
import { RequestIdMiddleware } from "./common/request-id.middleware.js";
import { HealthModule } from "./health/health.module.js";
import { AuthModule } from "./auth/auth.module.js";
@Module({ imports: [HealthModule] })
@Module({ imports: [HealthModule, AuthModule] })
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestIdMiddleware).forRoutes("{*path}");
+57
View File
@@ -0,0 +1,57 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
codeHmac,
phoneHmac,
resetAuthEnvironmentForTests,
validateAuthEnvironment,
} from "./auth.config.js";
const valid = {
PHONE_ENCRYPTION_KEY: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
PHONE_HMAC_KEY: "phone-hmac-key-that-is-at-least-32-bytes",
VERIFICATION_CODE_HMAC_KEY: "code-hmac-key-that-is-at-least-32-bytes!",
REFRESH_TOKEN_HMAC_KEY: "refresh-key-that-is-at-least-thirty-two-bytes",
JWT_SECRET: "jwt-secret-that-is-at-least-thirty-two-bytes",
};
describe("auth environment", () => {
const original = { ...process.env };
beforeEach(() => {
process.env.NODE_ENV = "test";
Object.assign(process.env, valid);
resetAuthEnvironmentForTests();
});
afterEach(() => {
process.env = { ...original, NODE_ENV: "test" };
resetAuthEnvironmentForTests();
});
it.each(Object.keys(valid))("fails fast when %s is missing", (name) => {
delete process.env[name];
expect(() => validateAuthEnvironment()).toThrow(`${name} is required`);
});
it.each(Object.keys(valid))("fails fast when %s is too short", (name) => {
process.env[name] = "short";
expect(() => validateAuthEnvironment()).toThrow();
});
it("rejects duplicate keys including the verification-code key", () => {
process.env.VERIFICATION_CODE_HMAC_KEY = process.env.PHONE_HMAC_KEY;
expect(() => validateAuthEnvironment()).toThrow(
"Auth secrets must be independent",
);
});
it("uses a verification-code key independent from the phone key", () => {
validateAuthEnvironment();
const phoneDigest = phoneHmac("+8613800138000");
const first = codeHmac(phoneDigest, "123456");
resetAuthEnvironmentForTests();
process.env.VERIFICATION_CODE_HMAC_KEY =
"different-code-key-that-is-at-least-32-bytes";
validateAuthEnvironment();
expect(phoneHmac("+8613800138000")).toBe(phoneDigest);
expect(codeHmac(phoneDigest, "123456")).not.toBe(first);
});
});
+91
View File
@@ -0,0 +1,91 @@
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;
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");
secrets = loaded;
}
export function resetAuthEnvironmentForTests(): void {
if (process.env.NODE_ENV !== "test")
throw new Error("Auth environment reset is test-only");
secrets = undefined;
}
function authSecrets(): AuthSecrets {
if (!secrets) validateAuthEnvironment();
return secrets!;
}
export const jwtSecret = (): Buffer => authSecrets().JWT_SECRET;
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;
};
+141
View File
@@ -0,0 +1,141 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Inject,
Post,
Req,
Res,
UseGuards,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { parse as parseCookie } from "cookie";
import { ErrorCode } from "@drift/contracts";
import { DomainException } from "../common/domain.exception.js";
import { PrismaService } from "../database/prisma.service.js";
import { AuthGuard } from "./auth.guard.js";
import { AuthService, type TokenPair } from "./auth.service.js";
import { CurrentUser } from "./current-user.decorator.js";
import type { AccessClaims } from "./token.service.js";
import { LoginDto, SendSmsDto } from "./dto.js";
const COOKIE = "refresh_token";
@Controller()
export class AuthController {
constructor(
@Inject(AuthService) private readonly auth: AuthService,
@Inject(PrismaService) private readonly prisma: PrismaService,
) {}
@Post("auth/sms/send") send(@Body() dto: SendSmsDto, @Req() req: Request) {
return this.auth.sendSms(dto.phone, dto.deviceId, this.ip(req));
}
@Post("auth/sms/login") async login(
@Body() dto: LoginDto,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const pair = await this.auth.login(
dto.phone,
dto.code,
dto.deviceId,
this.ip(req),
);
this.cookie(res, pair.refreshToken);
return this.publicPair(pair);
}
@Post("auth/token/refresh") @HttpCode(HttpStatus.OK) async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
this.origin(req);
const pair = await this.auth.refresh(this.readCookie(req));
this.cookie(res, pair.refreshToken);
return this.publicPair(pair);
}
@Post("auth/logout") @HttpCode(HttpStatus.OK) async logout(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
this.origin(req);
await this.auth.logout(this.readCookieOptional(req));
res.clearCookie(COOKIE, {
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
path: "/api/v1/auth",
});
return { loggedOut: true };
}
@Get("me") @UseGuards(AuthGuard) async me(@CurrentUser() user: AccessClaims) {
const account = await this.prisma.account.findUniqueOrThrow({
where: { id: user.sub },
include: { anonymousProfile: true },
});
return {
accountId: account.id,
publicId: account.anonymousProfile?.publicId ?? null,
nickname: account.anonymousProfile?.nickname ?? null,
};
}
private publicPair(pair: TokenPair) {
return {
accessToken: pair.accessToken,
expiresIn: pair.expiresIn,
tokenType: "Bearer",
};
}
private cookie(res: Response, value: string) {
res.cookie(COOKIE, value, {
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
path: "/api/v1/auth",
maxAge: Number(process.env.REFRESH_TOKEN_TTL_SECONDS ?? 2592000) * 1000,
});
}
private origin(req: Request) {
const origin = req.headers.origin;
if (origin !== process.env.WEB_ORIGIN)
throw new DomainException(
ErrorCode.AUTH_ORIGIN_FORBIDDEN,
"Forbidden origin",
HttpStatus.FORBIDDEN,
);
}
private readCookieOptional(req: Request) {
const raw = req.headers.cookie;
if (!raw) return undefined;
try {
return parseCookie(raw)[COOKIE];
} catch {
return undefined;
}
}
private readCookie(req: Request) {
const value = this.readCookieOptional(req);
if (!value)
throw new DomainException(
ErrorCode.AUTH_INVALID_CREDENTIALS,
"Invalid credentials",
HttpStatus.UNAUTHORIZED,
);
return value;
}
private ip(req: Request) {
return req.ip || req.socket.remoteAddress || "unknown";
}
}
Reflect.defineMetadata(
"design:paramtypes",
[SendSmsDto, Object],
AuthController.prototype,
"send",
);
Reflect.defineMetadata(
"design:paramtypes",
[LoginDto, Object, Object],
AuthController.prototype,
"login",
);
+499
View File
@@ -0,0 +1,499 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */
import "reflect-metadata";
import { Logger, type INestApplication } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import { PrismaClient } from "@prisma/client";
import { randomUUID } from "node:crypto";
import { createServer, type Socket } from "node:net";
import { createClient } from "redis";
import request from "supertest";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { assertSafeTestDatabaseUrl } from "../../../../prisma/database-safety.js";
import { AppModule } from "../app.module.js";
import { configureApp } from "../main.js";
import { RedisService } from "../redis/redis.service.js";
process.env.NODE_ENV = "test";
process.env.WEB_ORIGIN = "http://localhost:3000";
process.env.PHONE_ENCRYPTION_KEY =
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
process.env.VERIFICATION_CODE_HMAC_KEY =
"test-code-hmac-key-with-at-least-32-bytes";
process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes";
process.env.REFRESH_TOKEN_HMAC_KEY =
"test-refresh-hmac-key-with-at-least-32-bytes";
process.env.REDIS_KEY_PREFIX = "drift:auth:e2e:";
const phone = "138 0013 8000",
deviceId = "device-test-001",
origin = "http://localhost:3000";
const prisma = new PrismaClient();
const redis = createClient({
url: process.env.REDIS_URL ?? "redis://127.0.0.1:56379",
});
const cookieValue = (header: string[]) => header[0]?.split(";")[0] ?? "";
describe("auth real PostgreSQL/Redis", () => {
let app: INestApplication;
beforeAll(async () => {
assertSafeTestDatabaseUrl(process.env.DATABASE_URL ?? "");
await prisma.$connect();
await redis.connect();
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
});
beforeEach(async () => {
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`);
for (const key of await redis.keys("drift:auth:e2e:*"))
await redis.del(key);
delete process.env.SMS_SEND_LIMIT;
delete process.env.SMS_SEND_IP_LIMIT;
delete process.env.SMS_VERIFY_LIMIT;
delete process.env.SMS_CODE_TTL_SECONDS;
delete process.env.ACCESS_TOKEN_TTL_SECONDS;
delete process.env.TRUST_PROXY;
});
afterAll(async () => {
await app?.close();
await redis.quit();
await prisma.$disconnect();
});
async function send() {
return request(app.getHttpServer())
.post("/api/v1/auth/sms/send")
.send({ phone, deviceId });
}
async function login() {
const sent = await send();
return request(app.getHttpServer())
.post("/api/v1/auth/sms/login")
.send({ phone, deviceId, code: sent.body.data.debugCode });
}
it("sends, logs in with one-time code, stores encrypted identity, sets strict cookie, and guards me", async () => {
const sent = await send();
expect(sent.status).toBe(201);
expect(sent.body.data.debugCode).toMatch(/^\d{6}$/);
expect(JSON.stringify(sent.body)).not.toContain("13800138000");
const logged = await request(app.getHttpServer())
.post("/api/v1/auth/sms/login")
.send({ phone, deviceId, code: sent.body.data.debugCode })
.expect(201);
const cookies = logged.headers["set-cookie"] as unknown as string[];
expect(cookies[0]).toContain("HttpOnly");
expect(cookies[0]).toContain("SameSite=Strict");
expect(cookies[0]).toContain("Path=/api/v1/auth");
expect(cookies[0]).not.toContain("Secure");
expect(logged.body.data.accessToken).toEqual(expect.any(String));
const account = await prisma.account.findFirstOrThrow();
expect(Buffer.from(account.phoneCiphertext).toString()).not.toContain(
"13800138000",
);
await request(app.getHttpServer())
.post("/api/v1/auth/sms/login")
.send({ phone, deviceId, code: sent.body.data.debugCode })
.expect(401);
const me = await request(app.getHttpServer())
.get("/api/v1/me")
.set("Authorization", `Bearer ${logged.body.data.accessToken}`)
.expect(200);
expect(me.body.data.accountId).toBe(account.id);
});
it("rejects wrong codes and enforces atomic rate limit with TTL", async () => {
await send();
await request(app.getHttpServer())
.post("/api/v1/auth/sms/login")
.send({ phone, deviceId, code: "000000" })
.expect(401);
process.env.SMS_SEND_LIMIT = "1";
const oldKeys = await redis.keys("drift:auth:e2e:*");
for (const key of oldKeys) await redis.del(key);
await send();
const limited = await send();
expect(limited.status).toBe(429);
expect(limited.body.code).toBe("RATE_LIMITED");
const keys = await redis.keys("drift:auth:e2e:limit:send-phone:*");
expect(await redis.ttl(keys[0]!)).toBeGreaterThan(0);
});
it("omits debugCode in production", async () => {
process.env.NODE_ENV = "production";
try {
const response = await send();
expect(response.body.data).not.toHaveProperty("debugCode");
} finally {
process.env.NODE_ENV = "test";
}
});
it("rotates refresh, detects old-token replay, and revokes the session", async () => {
const logged = await login();
const oldCookie = cookieValue(
logged.headers["set-cookie"] as unknown as string[],
);
const refreshed = await request(app.getHttpServer())
.post("/api/v1/auth/token/refresh")
.set("Origin", origin)
.set("Cookie", oldCookie)
.expect(200);
const nextCookie = cookieValue(
refreshed.headers["set-cookie"] as unknown as string[],
);
expect(nextCookie).not.toBe(oldCookie);
const replay = await request(app.getHttpServer())
.post("/api/v1/auth/token/refresh")
.set("Origin", origin)
.set("Cookie", oldCookie)
.expect(401);
expect(replay.body.code).toBe("AUTH_REFRESH_REUSED");
await request(app.getHttpServer())
.post("/api/v1/auth/token/refresh")
.set("Origin", origin)
.set("Cookie", nextCookie)
.expect(401);
expect((await prisma.session.findFirstOrThrow()).revokedAt).not.toBeNull();
});
it("uses the first refresh cookie rather than allowing a duplicate to overwrite it", async () => {
const logged = await login();
const valid = cookieValue(
logged.headers["set-cookie"] as unknown as string[],
);
const malicious = `${randomUUID()}.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA`;
await request(app.getHttpServer())
.post("/api/v1/auth/token/refresh")
.set("Origin", origin)
.set("Cookie", `${valid}; refresh_token=${malicious}`)
.expect(200);
const second = await login();
const secondValid = cookieValue(
second.headers["set-cookie"] as unknown as string[],
);
await request(app.getHttpServer())
.post("/api/v1/auth/token/refresh")
.set("Origin", origin)
.set("Cookie", `refresh_token=${malicious}; ${secondValid}`)
.expect(401);
});
it("allows only one concurrent refresh and leaves the family revoked", async () => {
const logged = await login();
const cookie = cookieValue(
logged.headers["set-cookie"] as unknown as string[],
);
const results = await Promise.all(
[1, 2].map(() =>
request(app.getHttpServer())
.post("/api/v1/auth/token/refresh")
.set("Origin", origin)
.set("Cookie", cookie),
),
);
expect(results.map((x) => x.status).sort()).toEqual([200, 401]);
const winner = results.find((result) => result.status === 200);
const winnerCookie = cookieValue(
winner?.headers["set-cookie"] as unknown as string[],
);
await request(app.getHttpServer())
.post("/api/v1/auth/token/refresh")
.set("Origin", origin)
.set("Cookie", winnerCookie)
.expect(401);
expect((await prisma.session.findFirstOrThrow()).revokedAt).not.toBeNull();
});
it("rejects tampered access and revoked/token-version sessions", async () => {
const logged = await login();
const token = logged.body.data.accessToken as string;
await request(app.getHttpServer())
.get("/api/v1/me")
.set("Authorization", `Bearer ${token}x`)
.expect(401);
const session = await prisma.session.findFirstOrThrow();
await prisma.account.update({
where: { id: session.accountId },
data: { tokenVersion: { increment: 1 } },
});
await request(app.getHttpServer())
.get("/api/v1/me")
.set("Authorization", `Bearer ${token}`)
.expect(401);
});
it("requires exact Origin for refresh/logout and makes logout idempotent", async () => {
const logged = await login();
const cookie = cookieValue(
logged.headers["set-cookie"] as unknown as string[],
);
await request(app.getHttpServer())
.post("/api/v1/auth/token/refresh")
.set("Origin", "https://evil.example")
.set("Cookie", cookie)
.expect(403);
await request(app.getHttpServer())
.post("/api/v1/auth/logout")
.set("Origin", "https://evil.example")
.set("Cookie", cookie)
.expect(403);
const first = await request(app.getHttpServer())
.post("/api/v1/auth/logout")
.set("Origin", origin)
.set("Cookie", cookie)
.expect(200);
expect((first.headers["set-cookie"] as unknown as string[])[0]).toContain(
"refresh_token=",
);
await request(app.getHttpServer())
.post("/api/v1/auth/logout")
.set("Origin", origin)
.set("Cookie", cookie)
.expect(200);
expect((await prisma.session.findFirstOrThrow()).revokedAt).not.toBeNull();
});
it("marks production cookies Secure and never stores access tokens in cookies", async () => {
const sent = await send();
process.env.NODE_ENV = "production";
try {
const logged = await request(app.getHttpServer())
.post("/api/v1/auth/sms/login")
.send({ phone, deviceId, code: sent.body.data.debugCode })
.expect(201);
const cookies = logged.headers["set-cookie"] as unknown as string[];
expect(cookies[0]).toContain("Secure");
expect(cookies.join(";")).not.toContain(logged.body.data.accessToken);
expect(cookies.join(";")).not.toContain("access_token=");
} finally {
process.env.NODE_ENV = "test";
}
});
it("expires SMS codes and blocks verification at SMS_VERIFY_LIMIT", async () => {
process.env.SMS_CODE_TTL_SECONDS = "1";
const expired = await send();
await new Promise((resolve) => setTimeout(resolve, 1100));
await request(app.getHttpServer())
.post("/api/v1/auth/sms/login")
.send({ phone, deviceId, code: expired.body.data.debugCode })
.expect(401);
process.env.SMS_VERIFY_LIMIT = "2";
for (const key of await redis.keys("drift:auth:e2e:limit:verify-*"))
await redis.del(key);
await send();
for (let attempt = 0; attempt < 2; attempt += 1)
await request(app.getHttpServer())
.post("/api/v1/auth/sms/login")
.send({ phone, deviceId, code: "000000" })
.expect(401);
const limited = await request(app.getHttpServer())
.post("/api/v1/auth/sms/login")
.send({ phone, deviceId, code: "000000" })
.expect(429);
expect(limited.body.code).toBe("RATE_LIMITED");
});
it("rejects actually expired access tokens", async () => {
process.env.ACCESS_TOKEN_TTL_SECONDS = "1";
const logged = await login();
await new Promise((resolve) => setTimeout(resolve, 1100));
const denied = await request(app.getHttpServer())
.get("/api/v1/me")
.set("Authorization", `Bearer ${logged.body.data.accessToken}`)
.expect(401);
expect(denied.body.code).toBe("AUTH_TOKEN_EXPIRED");
});
it("issues only minimum HS256 claims for the configured issuer and audience", async () => {
const logged = await login();
const [encodedHeader, encodedClaims] = (
logged.body.data.accessToken as string
).split(".");
const header = JSON.parse(
Buffer.from(encodedHeader!, "base64url").toString(),
);
const claims = JSON.parse(
Buffer.from(encodedClaims!, "base64url").toString(),
);
expect(header).toEqual({ alg: "HS256", typ: "JWT" });
expect(Object.keys(claims).sort()).toEqual([
"aud",
"device_id",
"exp",
"iat",
"iss",
"scopes",
"session_id",
"sub",
"token_version",
]);
expect(claims).toMatchObject({
iss: "drift-api",
aud: "drift-web",
scopes: ["user"],
});
});
it("independently rejects a revoked session", async () => {
const logged = await login();
const session = await prisma.session.findFirstOrThrow();
await prisma.session.update({
where: { id: session.id },
data: { revokedAt: new Date() },
});
await request(app.getHttpServer())
.get("/api/v1/me")
.set("Authorization", `Bearer ${logged.body.data.accessToken}`)
.expect(401);
});
it("rejects a suspended account", async () => {
const logged = await login();
const session = await prisma.session.findFirstOrThrow();
await prisma.account.update({
where: { id: session.accountId },
data: { status: "SUSPENDED" },
});
await request(app.getHttpServer())
.get("/api/v1/me")
.set("Authorization", `Bearer ${logged.body.data.accessToken}`)
.expect(401);
});
it.each([
{ phone: "not-a-phone", deviceId },
{ phone, deviceId: "short" },
{ phone, deviceId: "x".repeat(129) },
{ phone, deviceId: "bad device!" },
])("validates phone and deviceId DTO input %#", async (body) => {
await request(app.getHttpServer())
.post("/api/v1/auth/sms/send")
.send(body)
.expect(400);
});
it("uses Express trust proxy parsing as the real IP rate-limit dimension", async () => {
process.env.SMS_SEND_IP_LIMIT = "1";
await request(app.getHttpServer())
.post("/api/v1/auth/sms/send")
.set("X-Forwarded-For", "198.51.100.1")
.send({ phone: "13800138001", deviceId: "device-ip-001" })
.expect(201);
await request(app.getHttpServer())
.post("/api/v1/auth/sms/send")
.set("X-Forwarded-For", "198.51.100.2")
.send({ phone: "13800138002", deviceId: "device-ip-002" })
.expect(429);
for (const key of await redis.keys("drift:auth:e2e:*"))
await redis.del(key);
app.getHttpAdapter().getInstance().set("trust proxy", 1);
try {
await request(app.getHttpServer())
.post("/api/v1/auth/sms/send")
.set("X-Forwarded-For", "198.51.100.1")
.send({ phone: "13800138001", deviceId: "device-ip-001" })
.expect(201);
await request(app.getHttpServer())
.post("/api/v1/auth/sms/send")
.set("X-Forwarded-For", "198.51.100.2")
.send({ phone: "13800138002", deviceId: "device-ip-002" })
.expect(201);
} finally {
app.getHttpAdapter().getInstance().set("trust proxy", false);
}
});
it("does not log phone, code, JWT, refresh token, or Cookie", async () => {
const spies = (["log", "warn", "error", "debug", "verbose"] as const).map(
(method) =>
vi.spyOn(Logger.prototype, method).mockImplementation(() => undefined),
);
try {
const sent = await send();
const logged = await request(app.getHttpServer())
.post("/api/v1/auth/sms/login")
.send({ phone, deviceId, code: sent.body.data.debugCode })
.expect(201);
const cookie = cookieValue(
logged.headers["set-cookie"] as unknown as string[],
);
await request(app.getHttpServer())
.post("/api/v1/auth/token/refresh")
.set("Origin", origin)
.set("Cookie", cookie)
.expect(200);
const output = JSON.stringify(spies.flatMap((spy) => spy.mock.calls));
for (const secret of [
"13800138000",
sent.body.data.debugCode,
logged.body.data.accessToken,
cookie,
])
expect(output).not.toContain(secret);
expect(output).not.toContain("refresh_token");
} finally {
for (const spy of spies) spy.mockRestore();
}
});
it("does not revoke a session for a random invalid refresh token", async () => {
await login();
const session = await prisma.session.findFirstOrThrow();
await request(app.getHttpServer())
.post("/api/v1/auth/token/refresh")
.set("Origin", origin)
.set(
"Cookie",
`other=1; refresh_token=${session.id}.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA; tail=2`,
)
.expect(401);
expect(
(await prisma.session.findUniqueOrThrow({ where: { id: session.id } }))
.revokedAt,
).toBeNull();
});
it("recovers Auth Redis after a connection deadline and closes its sockets", async () => {
const sockets = new Set<Socket>();
const server = createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
const address = server.address();
if (!address || typeof address === "string")
throw new Error("missing port");
const previousUrl = process.env.REDIS_URL;
const previousTimeout = process.env.REDIS_OPERATION_TIMEOUT_MS;
process.env.REDIS_URL = `redis://127.0.0.1:${address.port}`;
process.env.REDIS_OPERATION_TIMEOUT_MS = "500";
const service = new RedisService();
const failedClient = service.client;
let recoveredClient = failedClient;
try {
await expect(service.ensureConnected()).rejects.toThrow(/timed out/);
expect(failedClient.isOpen).toBe(false);
process.env.REDIS_URL = previousUrl ?? "redis://127.0.0.1:56379";
await expect(service.ensureConnected()).resolves.toBeUndefined();
recoveredClient = service.client;
expect(recoveredClient).not.toBe(failedClient);
expect(recoveredClient.isReady).toBe(true);
} finally {
await service.onModuleDestroy();
expect(recoveredClient.isOpen).toBe(false);
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
process.env.REDIS_URL = previousUrl;
process.env.REDIS_OPERATION_TIMEOUT_MS = previousTimeout;
}
});
});
+49
View File
@@ -0,0 +1,49 @@
import {
CanActivate,
ExecutionContext,
HttpStatus,
Inject,
Injectable,
} from "@nestjs/common";
import { ErrorCode } from "@drift/contracts";
import type { Request } from "express";
import { DomainException } from "../common/domain.exception.js";
import { PrismaService } from "../database/prisma.service.js";
import { TokenService } from "./token.service.js";
import type { AuthenticatedRequest } from "./current-user.decorator.js";
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(TokenService) private readonly tokens: TokenService,
) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const req = ctx.switchToHttp().getRequest<Request & AuthenticatedRequest>();
const auth = req.headers.authorization;
if (!auth?.startsWith("Bearer ")) throw this.denied();
const claims = this.tokens.verifyAccess(auth.slice(7));
const session = await this.prisma.session.findUnique({
where: { id: claims.session_id },
include: { account: true },
});
if (
!session ||
session.accountId !== claims.sub ||
session.deviceId !== claims.device_id ||
session.revokedAt ||
session.expiresAt <= new Date() ||
session.account.status !== "ACTIVE" ||
session.account.tokenVersion !== claims.token_version
)
throw this.denied();
req.user = claims;
return true;
}
private denied() {
return new DomainException(
ErrorCode.AUTH_UNAUTHORIZED,
"Unauthorized",
HttpStatus.UNAUTHORIZED,
);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller.js";
import { AuthGuard } from "./auth.guard.js";
import { AuthService } from "./auth.service.js";
import { TokenService } from "./token.service.js";
import { DatabaseModule } from "../database/database.module.js";
import { RedisModule } from "../redis/redis.module.js";
@Module({
imports: [DatabaseModule, RedisModule],
controllers: [AuthController],
providers: [AuthService, TokenService, AuthGuard],
exports: [AuthGuard, TokenService],
})
export class AuthModule {}
+313
View File
@@ -0,0 +1,313 @@
import { HttpStatus, Inject, Injectable } from "@nestjs/common";
import { ErrorCode } from "@drift/contracts";
import { Prisma, type Account } from "@prisma/client";
import { randomInt, randomUUID } from "node:crypto";
import { DomainException } from "../common/domain.exception.js";
import { PrismaService } from "../database/prisma.service.js";
import { RedisService } from "../redis/redis.service.js";
import {
codeHmac,
encryptPhone,
envInt,
phoneHmac,
refreshHmac,
safeEqual,
} from "./auth.config.js";
import { TokenService } from "./token.service.js";
export interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
@Injectable()
export class AuthService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(RedisService) private readonly redis: RedisService,
@Inject(TokenService) private readonly tokens: TokenService,
) {}
normalizePhone(raw: string): string {
const compact = raw.replace(/[\s()-]/g, "");
const local = compact.startsWith("+86")
? compact.slice(3)
: compact.startsWith("86") && compact.length === 13
? compact.slice(2)
: compact;
if (!/^1[3-9]\d{9}$/.test(local))
throw new DomainException(
ErrorCode.AUTH_INVALID_CREDENTIALS,
"Invalid credentials",
HttpStatus.UNAUTHORIZED,
);
return `+86${local}`;
}
private prefix() {
return process.env.REDIS_KEY_PREFIX ?? "drift:auth:";
}
private async limit(
kind: string,
parts: string[],
max: number,
ttl: number,
): Promise<void> {
await this.redis.ensureConnected();
const key = `${this.prefix()}limit:${kind}:${parts.join(":")}`;
const result = (await this.redis.client.eval(
`local n=redis.call('INCR',KEYS[1]); if n==1 then redis.call('EXPIRE',KEYS[1],ARGV[1]) end; return n`,
{ keys: [key], arguments: [String(ttl)] },
)) as number;
if (result > max)
throw new DomainException(
ErrorCode.RATE_LIMITED,
"Too many requests",
HttpStatus.TOO_MANY_REQUESTS,
);
}
async sendSms(
rawPhone: string,
deviceId: string,
ip: string,
): Promise<{ sent: true; debugCode?: string }> {
const phone = this.normalizePhone(rawPhone);
const digest = phoneHmac(phone);
await Promise.all([
this.limit(
"send-phone",
[digest],
envInt("SMS_SEND_LIMIT", 3),
envInt("SMS_RATE_WINDOW_SECONDS", 300),
),
this.limit(
"send-device",
[deviceId],
envInt("SMS_SEND_DEVICE_LIMIT", 5),
envInt("SMS_RATE_WINDOW_SECONDS", 300),
),
this.limit(
"send-ip",
[phoneHmac(ip)],
envInt("SMS_SEND_IP_LIMIT", 10),
envInt("SMS_RATE_WINDOW_SECONDS", 300),
),
]);
const code = randomInt(0, 1000000).toString().padStart(6, "0");
await this.redis.client.set(
`${this.prefix()}code:${digest}`,
codeHmac(digest, code),
{ EX: envInt("SMS_CODE_TTL_SECONDS", 300) },
);
const result: { sent: true; debugCode?: string } = { sent: true };
if (process.env.NODE_ENV !== "production") result.debugCode = code;
return result;
}
async login(
rawPhone: string,
code: string,
deviceId: string,
ip: string,
): Promise<TokenPair> {
const phone = this.normalizePhone(rawPhone);
const digest = phoneHmac(phone);
await Promise.all([
this.limit(
"verify-phone",
[digest],
envInt("SMS_VERIFY_LIMIT", 5),
envInt("SMS_RATE_WINDOW_SECONDS", 300),
),
this.limit(
"verify-combined",
[digest, phoneHmac(ip), deviceId],
envInt("SMS_VERIFY_COMBINED_LIMIT", 5),
envInt("SMS_RATE_WINDOW_SECONDS", 300),
),
]);
const key = `${this.prefix()}code:${digest}`;
const consumed = await this.redis.client.eval(
`local v=redis.call('GET',KEYS[1]); if v and v==ARGV[1] then redis.call('DEL',KEYS[1]); return 1 end; return 0`,
{ keys: [key], arguments: [codeHmac(digest, code)] },
);
if (Number(consumed) !== 1) throw this.invalid();
let account = await this.prisma.account.findUnique({
where: { phoneHmac: digest },
});
if (!account) account = await this.createAccount(phone, digest);
if (account.status !== "ACTIVE") throw this.invalid();
return this.createSession(account, deviceId);
}
private async createAccount(phone: string, digest: string): Promise<Account> {
try {
return await this.prisma.account.create({
data: {
phoneHmac: digest,
phoneCiphertext: new Uint8Array(encryptPhone(phone)),
},
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
const found = await this.prisma.account.findUnique({
where: { phoneHmac: digest },
});
if (found) return found;
}
throw error;
}
}
private async createSession(
account: Account,
deviceId: string,
): Promise<TokenPair> {
const id = randomUUID();
const refresh = this.tokens.newRefresh(id);
const expiresAt = new Date(
Date.now() + envInt("REFRESH_TOKEN_TTL_SECONDS", 2592000) * 1000,
);
await this.prisma.session.create({
data: {
id,
accountId: account.id,
deviceId,
refreshTokenHash: refreshHmac(refresh),
tokenFamily: id,
expiresAt,
},
});
return this.pair(account, id, deviceId, refresh);
}
private pair(
account: Account,
sessionId: string,
deviceId: string,
refreshToken: string,
): TokenPair {
const expiresIn = envInt("ACCESS_TOKEN_TTL_SECONDS", 900);
return {
accessToken: this.tokens.issueAccess({
sub: account.id,
session_id: sessionId,
device_id: deviceId,
scopes: ["user"],
token_version: account.tokenVersion,
}),
refreshToken,
expiresIn,
};
}
async refresh(token: string): Promise<TokenPair> {
const parsed = this.parseRefresh(token);
const hash = refreshHmac(token);
const observed = await this.prisma.session.findUnique({
where: { id: parsed },
});
if (
observed?.previousTokenHash &&
safeEqual(observed.previousTokenHash, hash)
)
return this.revokeReusedFamily(observed.tokenFamily);
try {
const result = await this.prisma.$transaction(
async (tx): Promise<TokenPair | { reusedFamily: string }> => {
const session = await tx.session.findUnique({
where: { id: parsed },
include: { account: true },
});
if (!session) throw this.invalid();
if (
session.previousTokenHash &&
safeEqual(session.previousTokenHash, hash)
)
return { reusedFamily: session.tokenFamily };
if (
!safeEqual(session.refreshTokenHash, hash) ||
session.revokedAt ||
session.expiresAt <= new Date() ||
session.account.status !== "ACTIVE"
)
throw this.invalid();
const next = this.tokens.newRefresh(session.id);
const changed = await tx.session.updateMany({
where: { id: session.id, refreshTokenHash: hash, revokedAt: null },
data: {
previousTokenHash: hash,
refreshTokenHash: refreshHmac(next),
generation: { increment: 1 },
rotatedAt: new Date(),
},
});
if (changed.count !== 1) return { reusedFamily: session.tokenFamily };
return this.pair(
session.account,
session.id,
session.deviceId ?? "",
next,
);
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
if ("reusedFamily" in result)
return this.revokeReusedFamily(result.reusedFamily);
return result;
} catch (error) {
if (error instanceof DomainException) throw error;
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2034"
) {
const session = await this.prisma.session.findUnique({
where: { id: parsed },
});
if (
session?.previousTokenHash &&
safeEqual(session.previousTokenHash, hash)
)
return this.revokeReusedFamily(session.tokenFamily);
}
throw error;
}
}
private async revokeReusedFamily(family: string): Promise<never> {
await this.prisma.session.updateMany({
where: { tokenFamily: family },
data: { revokedAt: new Date() },
});
throw new DomainException(
ErrorCode.AUTH_REFRESH_REUSED,
"Refresh token reused",
HttpStatus.UNAUTHORIZED,
);
}
async logout(token: string | undefined): Promise<void> {
if (!token) return;
let id: string;
try {
id = this.parseRefresh(token);
} catch {
return;
}
const hash = refreshHmac(token);
await this.prisma.session.updateMany({
where: {
id,
OR: [{ refreshTokenHash: hash }, { previousTokenHash: hash }],
},
data: { revokedAt: new Date() },
});
}
private parseRefresh(token: string): string {
const id = token.split(".")[0];
if (!id || !/^[0-9a-f-]{36}$/.test(id)) throw this.invalid();
return id;
}
private invalid() {
return new DomainException(
ErrorCode.AUTH_INVALID_CREDENTIALS,
"Invalid credentials",
HttpStatus.UNAUTHORIZED,
);
}
}
@@ -0,0 +1,9 @@
import { createParamDecorator, type ExecutionContext } from "@nestjs/common";
import type { AccessClaims } from "./token.service.js";
export interface AuthenticatedRequest {
user?: AccessClaims;
}
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) =>
ctx.switchToHttp().getRequest<AuthenticatedRequest>().user,
);
+14
View File
@@ -0,0 +1,14 @@
import {
IsPhoneNumber,
IsString,
Matches,
MaxLength,
MinLength,
} from "class-validator";
export class SendSmsDto {
@IsString() @MinLength(6) @MaxLength(32) @IsPhoneNumber("CN") phone!: string;
@IsString() @Matches(/^[A-Za-z0-9._:-]{8,128}$/) deviceId!: string;
}
export class LoginDto extends SendSmsDto {
@IsString() @Matches(/^\d{6}$/) code!: string;
}
+82
View File
@@ -0,0 +1,82 @@
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");
@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 = JSON.parse(Buffer.from(h, "base64url").toString()) as {
alg?: string;
};
if (header.alg !== "HS256") throw new Error();
const expected = createHmac("sha256", jwtSecret())
.update(`${h}.${p}`)
.digest();
const actual = Buffer.from(s, "base64url");
if (
expected.length !== actual.length ||
!timingSafeEqual(expected, actual)
)
throw new Error();
const claims = JSON.parse(
Buffer.from(p, "base64url").toString(),
) as AccessClaims;
if (
claims.iss !== (process.env.JWT_ISSUER ?? "drift-api") ||
claims.aud !== (process.env.JWT_AUDIENCE ?? "drift-web")
)
throw new Error();
if (claims.exp <= Math.floor(Date.now() / 1000))
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(sessionId: string): string {
return `${sessionId}.${randomBytes(32).toString("base64url")}`;
}
}
+5
View File
@@ -0,0 +1,5 @@
import { Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service.js";
@Module({ providers: [PrismaService], exports: [PrismaService] })
export class DatabaseModule {}
+9
View File
@@ -2,12 +2,20 @@ import "reflect-metadata";
import { ValidationPipe, type INestApplication } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import helmet from "helmet";
import type { Application } from "express";
import { AppModule } from "./app.module.js";
import { validateAuthEnvironment } from "./auth/auth.config.js";
import { HttpResponseInterceptor } from "./common/http-response.interceptor.js";
import { DomainExceptionFilter } from "./common/domain-exception.filter.js";
export function configureApp(app: INestApplication): void {
validateAuthEnvironment();
const allowedOrigin = process.env.WEB_ORIGIN ?? "http://localhost:3000";
const adapter = app.getHttpAdapter?.() as
{ getInstance(): Application } | undefined;
adapter
?.getInstance()
.set("trust proxy", process.env.TRUST_PROXY === "true" ? 1 : false);
app.setGlobalPrefix("api/v1");
app.use(helmet());
app.useGlobalPipes(
@@ -35,6 +43,7 @@ type AppFactory = () => Promise<INestApplication>;
export async function bootstrap(
appFactory: AppFactory = () => NestFactory.create(AppModule),
): Promise<void> {
validateAuthEnvironment();
const app = await appFactory();
configureApp(app);
app.enableShutdownHooks();
+4
View File
@@ -0,0 +1,4 @@
import { Module } from "@nestjs/common";
import { RedisService } from "./redis.service.js";
@Module({ providers: [RedisService], exports: [RedisService] })
export class RedisModule {}
+137
View File
@@ -0,0 +1,137 @@
import {
Inject,
Injectable,
Logger,
Optional,
type OnModuleDestroy,
type OnModuleInit,
} from "@nestjs/common";
import { createClient } from "redis";
type RedisClient = ReturnType<typeof createClient>;
type RedisClientFactory = () => RedisClient;
export const AUTH_REDIS_CLIENT_FACTORY = Symbol("AUTH_REDIS_CLIENT_FACTORY");
@Injectable()
export class RedisService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(RedisService.name);
private readonly timeoutMs: number;
private current: RedisClient | undefined;
private generation = 0;
private readonly connections = new WeakMap<RedisClient, Promise<void>>();
private readonly destroyed = new WeakSet<RedisClient>();
private readonly loggedErrors = new WeakSet<RedisClient>();
private shuttingDown = false;
constructor(
@Optional()
@Inject(AUTH_REDIS_CLIENT_FACTORY)
private readonly factory?: RedisClientFactory,
) {
const configured = Number(process.env.REDIS_OPERATION_TIMEOUT_MS ?? 1000);
this.timeoutMs =
Number.isFinite(configured) && configured > 0 ? configured : 1000;
}
get client(): RedisClient {
if (!this.current) {
if (this.shuttingDown) throw new Error("Auth Redis is shutting down");
const client = this.factory?.() ?? this.create();
client.on("error", () => {
if (this.loggedErrors.has(client)) return;
this.loggedErrors.add(client);
this.logger.warn({
event: "Auth Redis client error",
code: "REDIS_ERROR",
});
});
this.current = client;
this.generation += 1;
}
return this.current;
}
async ensureConnected(): Promise<void> {
if (this.shuttingDown) throw new Error("Auth Redis is shutting down");
const client = this.client;
const generation = this.generation;
if (client.isReady) return;
let pending = this.connections.get(client);
if (!pending) {
pending = this.withDeadline(client, generation, client.connect()).then(
() => undefined,
);
this.connections.set(client, pending);
}
try {
await pending;
} catch (error) {
this.destroyClient(client, generation);
throw error;
} finally {
if (this.connections.get(client) === pending)
this.connections.delete(client);
}
}
async onModuleInit(): Promise<void> {
/* Auth Redis connects lazily so unrelated modules remain isolated. */
}
async onModuleDestroy(): Promise<void> {
this.shuttingDown = true;
const client = this.current;
if (!client || !client.isOpen) return;
const generation = this.generation;
try {
await this.withDeadline(client, generation, client.quit());
} catch {
this.destroyClient(client, generation);
}
}
private create(): RedisClient {
return createClient({
url: process.env.REDIS_URL ?? "redis://127.0.0.1:6379",
socket: { connectTimeout: this.timeoutMs, reconnectStrategy: false },
});
}
private withDeadline<T>(
client: RedisClient,
generation: number,
operation: Promise<T>,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.destroyClient(client, generation);
reject(new Error("Auth Redis operation timed out"));
}, this.timeoutMs);
void operation.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(
error instanceof Error ? error : new Error("Auth Redis failed"),
);
},
);
});
}
private destroyClient(client: RedisClient, generation: number): void {
if (!this.destroyed.has(client)) {
this.destroyed.add(client);
try {
client.destroy();
} catch {
/* best-effort cleanup */
}
}
if (this.current === client && this.generation === generation)
this.current = undefined;
}
}
+2 -1
View File
@@ -8,5 +8,6 @@
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "vitest.config.ts"]
"include": ["src/**/*.ts", "vitest.config.ts"],
"exclude": ["src/**/*spec.ts"]
}
+4
View File
@@ -28,6 +28,10 @@ describe("contracts", () => {
"INTERNAL_ERROR",
"AUTH_TOKEN_EXPIRED",
"AUTH_REFRESH_REUSED",
"AUTH_INVALID_CREDENTIALS",
"AUTH_UNAUTHORIZED",
"RATE_LIMITED",
"AUTH_ORIGIN_FORBIDDEN",
"BOTTLE_DAILY_LIMIT",
"BOTTLE_POOL_EMPTY",
"BOTTLE_LEASE_EXPIRED",
+4
View File
@@ -6,6 +6,10 @@ export enum ErrorCode {
INTERNAL_ERROR = "INTERNAL_ERROR",
AUTH_TOKEN_EXPIRED = "AUTH_TOKEN_EXPIRED",
AUTH_REFRESH_REUSED = "AUTH_REFRESH_REUSED",
AUTH_INVALID_CREDENTIALS = "AUTH_INVALID_CREDENTIALS",
AUTH_UNAUTHORIZED = "AUTH_UNAUTHORIZED",
RATE_LIMITED = "RATE_LIMITED",
AUTH_ORIGIN_FORBIDDEN = "AUTH_ORIGIN_FORBIDDEN",
BOTTLE_DAILY_LIMIT = "BOTTLE_DAILY_LIMIT",
BOTTLE_POOL_EMPTY = "BOTTLE_POOL_EMPTY",
BOTTLE_LEASE_EXPIRED = "BOTTLE_LEASE_EXPIRED",
+12
View File
@@ -70,6 +70,9 @@ importers:
class-validator:
specifier: ^0.14.2
version: 0.14.4
cookie:
specifier: 1.1.1
version: 1.1.1
helmet:
specifier: ^8.1.0
version: 8.3.0
@@ -1076,6 +1079,13 @@ packages:
integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==,
}
cookie@1.1.1:
resolution:
{
integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==,
}
engines: { node: ">=18" }
"@types/chai@5.2.3":
resolution:
{
@@ -4002,6 +4012,8 @@ snapshots:
cookie@0.7.2: {}
cookie@1.1.1: {}
cookiejar@2.1.4: {}
cors@2.8.6:
@@ -0,0 +1,8 @@
ALTER TABLE "sessions" ADD COLUMN "token_family" UUID;
ALTER TABLE "sessions" ADD COLUMN "generation" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "sessions" ADD COLUMN "previous_token_hash" VARCHAR(255);
ALTER TABLE "sessions" ADD COLUMN "rotated_at" TIMESTAMPTZ(3);
UPDATE "sessions" SET "token_family" = "id" WHERE "token_family" IS NULL;
ALTER TABLE "sessions" ALTER COLUMN "token_family" SET NOT NULL;
CREATE UNIQUE INDEX "sessions_previous_token_hash_key" ON "sessions"("previous_token_hash");
CREATE INDEX "sessions_token_family_idx" ON "sessions"("token_family");
+5
View File
@@ -154,11 +154,16 @@ model Session {
deviceId String? @map("device_id") @db.VarChar(255)
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3)
tokenFamily String @map("token_family") @db.Uuid
generation Int @default(0)
previousTokenHash String? @unique @map("previous_token_hash") @db.VarChar(255)
rotatedAt DateTime? @map("rotated_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@index([accountId, expiresAt])
@@index([tokenFamily])
@@map("sessions")
}