From 6b453af364d1dcc174417db10ad752fab896b85d Mon Sep 17 00:00:00 2001 From: root Date: Mon, 14 Sep 2026 20:05:40 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8A=A0=E5=9B=BA=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E6=92=A4=E9=94=80=E4=B8=8E=E8=BF=90=E8=A1=8C=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + apps/api/src/auth/auth.config.spec.ts | 36 +++++++ apps/api/src/auth/auth.config.ts | 42 +++++++++ apps/api/src/auth/auth.controller.ts | 7 +- apps/api/src/auth/auth.e2e-spec.ts | 93 ++++++++++++++++++ apps/api/src/auth/auth.guard.ts | 1 + apps/api/src/auth/auth.service.ts | 68 +++++++++++--- apps/api/src/auth/token.service.spec.ts | 94 +++++++++++++++++++ apps/api/src/auth/token.service.ts | 53 +++++++++-- apps/api/src/main.ts | 4 +- .../0004_session_token_version/migration.sql | 9 ++ prisma/schema.prisma | 1 + tests/integration/database.spec.ts | 13 +++ 13 files changed, 397 insertions(+), 26 deletions(-) create mode 100644 apps/api/src/auth/token.service.spec.ts create mode 100644 prisma/migrations/0004_session_token_version/migration.sql diff --git a/.env.example b/.env.example index 498d621..b973b5a 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ # Copy this file to .env and replace test-only values for non-test environments. +NODE_ENV=production +DEMO_SMS_CODE_ENABLED=false 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 diff --git a/apps/api/src/auth/auth.config.spec.ts b/apps/api/src/auth/auth.config.spec.ts index 6141c81..2068fde 100644 --- a/apps/api/src/auth/auth.config.spec.ts +++ b/apps/api/src/auth/auth.config.spec.ts @@ -1,9 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { codeHmac, + demoSmsCodeEnabled, phoneHmac, resetAuthEnvironmentForTests, validateAuthEnvironment, + webOrigin, } from "./auth.config.js"; const valid = { @@ -12,6 +14,7 @@ const valid = { 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", + WEB_ORIGIN: "http://localhost:3000", }; describe("auth environment", () => { @@ -54,4 +57,37 @@ describe("auth environment", () => { expect(phoneHmac("+8613800138000")).toBe(phoneDigest); expect(codeHmac(phoneDigest, "123456")).not.toBe(first); }); + + it("fails closed when WEB_ORIGIN is missing or is not one absolute HTTP origin", () => { + for (const value of [ + undefined, + "localhost:3000", + "https://a.test/path", + "https://u:p@a.test", + "ftp://a.test", + ]) { + resetAuthEnvironmentForTests(); + if (value === undefined) delete process.env.WEB_ORIGIN; + else process.env.WEB_ORIGIN = value; + expect(() => validateAuthEnvironment()).toThrow(/WEB_ORIGIN/); + } + }); + + it("caches and exposes the validated web origin", () => { + validateAuthEnvironment(); + expect(webOrigin()).toBe("http://localhost:3000"); + }); + + it("returns demo codes only in test or explicitly enabled local development", () => { + delete process.env.DEMO_SMS_CODE_ENABLED; + expect(demoSmsCodeEnabled()).toBe(false); + process.env.DEMO_SMS_CODE_ENABLED = "true"; + expect(demoSmsCodeEnabled()).toBe(true); + process.env.NODE_ENV = "staging"; + expect(demoSmsCodeEnabled()).toBe(false); + process.env.NODE_ENV = "development"; + expect(demoSmsCodeEnabled()).toBe(true); + process.env.NODE_ENV = "production"; + expect(demoSmsCodeEnabled()).toBe(false); + }); }); diff --git a/apps/api/src/auth/auth.config.ts b/apps/api/src/auth/auth.config.ts index ec38d3e..437fc8e 100644 --- a/apps/api/src/auth/auth.config.ts +++ b/apps/api/src/auth/auth.config.ts @@ -26,6 +26,31 @@ const REQUIRED_KEYS = [ ] 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( @@ -38,18 +63,35 @@ export function validateAuthEnvironment(): void { ); 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) diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index a0d1458..5bb63ae 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -20,6 +20,7 @@ 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"; +import { envInt, webOrigin } from "./auth.config.js"; const COOKIE = "refresh_token"; @Controller() export class AuthController { @@ -49,7 +50,7 @@ export class AuthController { @Res({ passthrough: true }) res: Response, ) { this.origin(req); - const pair = await this.auth.refresh(this.readCookie(req)); + const pair = await this.auth.refresh(this.readCookie(req), this.ip(req)); this.cookie(res, pair.refreshToken); return this.publicPair(pair); } @@ -91,12 +92,12 @@ export class AuthController { sameSite: "strict", secure: process.env.NODE_ENV === "production", path: "/api/v1/auth", - maxAge: Number(process.env.REFRESH_TOKEN_TTL_SECONDS ?? 2592000) * 1000, + maxAge: envInt("REFRESH_TOKEN_TTL_SECONDS", 2592000) * 1000, }); } private origin(req: Request) { const origin = req.headers.origin; - if (origin !== process.env.WEB_ORIGIN) + if (origin !== webOrigin()) throw new DomainException( ErrorCode.AUTH_ORIGIN_FORBIDDEN, "Forbidden origin", diff --git a/apps/api/src/auth/auth.e2e-spec.ts b/apps/api/src/auth/auth.e2e-spec.ts index b8b615c..73b5784 100644 --- a/apps/api/src/auth/auth.e2e-spec.ts +++ b/apps/api/src/auth/auth.e2e-spec.ts @@ -33,6 +33,7 @@ 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:"; +process.env.DEMO_SMS_CODE_ENABLED = "true"; const phone = "138 0013 8000", deviceId = "device-test-001", origin = "http://localhost:3000"; @@ -67,6 +68,8 @@ describe("auth real PostgreSQL/Redis", () => { delete process.env.SMS_CODE_TTL_SECONDS; delete process.env.ACCESS_TOKEN_TTL_SECONDS; delete process.env.TRUST_PROXY; + delete process.env.REFRESH_RATE_LIMIT; + process.env.DEMO_SMS_CODE_ENABLED = "true"; }); afterAll(async () => { await app?.close(); @@ -172,6 +175,18 @@ describe("auth real PostgreSQL/Redis", () => { process.env.NODE_ENV = "test"; } }); + it("omits debugCode by default and never leaks it in staging", async () => { + delete process.env.DEMO_SMS_CODE_ENABLED; + expect((await send()).body.data).not.toHaveProperty("debugCode"); + process.env.DEMO_SMS_CODE_ENABLED = "true"; + process.env.NODE_ENV = "staging"; + try { + expect((await send()).body.data).not.toHaveProperty("debugCode"); + } finally { + process.env.NODE_ENV = "test"; + process.env.DEMO_SMS_CODE_ENABLED = "true"; + } + }); it("rotates refresh, detects old-token replay, and revokes the session", async () => { const logged = await login(); const oldCookie = cookieValue( @@ -285,6 +300,76 @@ describe("auth real PostgreSQL/Redis", () => { .expect(401); expect((await prisma.session.findFirstOrThrow()).revokedAt).not.toBeNull(); }); + it("revokes the family when account tokenVersion changes before refresh", async () => { + const logged = await login(); + const cookie = cookieValue( + logged.headers["set-cookie"] as unknown as string[], + ); + const session = await prisma.session.findFirstOrThrow(); + expect(session.tokenVersion).toBe(0); + await prisma.account.update({ + where: { id: session.accountId }, + data: { tokenVersion: { increment: 1 } }, + }); + await request(app.getHttpServer()) + .post("/api/v1/auth/token/refresh") + .set("Origin", origin) + .set("Cookie", cookie) + .expect(401); + expect( + (await prisma.session.findUniqueOrThrow({ where: { id: session.id } })) + .revokedAt, + ).not.toBeNull(); + }); + + it("deletes expired refresh history but retains unexpired history", async () => { + const logged = await login(); + const session = await prisma.session.findFirstOrThrow(); + await prisma.refreshToken.create({ + data: { + tokenHash: "a".repeat(64), + sessionId: session.id, + generation: 99, + expiresAt: new Date(Date.now() - 1000), + }, + }); + 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); + expect( + await prisma.refreshToken.findUnique({ + where: { tokenHash: "a".repeat(64) }, + }), + ).toBeNull(); + expect( + await prisma.refreshToken.count({ where: { sessionId: session.id } }), + ).toBeGreaterThan(0); + }); + + it("rate limits refresh by hashed token/session and IP", async () => { + process.env.REFRESH_RATE_LIMIT = "1"; + 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", origin) + .set("Cookie", cookie) + .expect(200); + const limited = await request(app.getHttpServer()) + .post("/api/v1/auth/token/refresh") + .set("Origin", origin) + .set("Cookie", cookie) + .expect(429); + expect(limited.body.code).toBe("RATE_LIMITED"); + }); + it("rejects tampered access and revoked/token-version sessions", async () => { const logged = await login(); const token = logged.body.data.accessToken as string; @@ -307,6 +392,14 @@ describe("auth real PostgreSQL/Redis", () => { const cookie = cookieValue( logged.headers["set-cookie"] as unknown as string[], ); + await request(app.getHttpServer()) + .post("/api/v1/auth/token/refresh") + .set("Cookie", cookie) + .expect(403); + await request(app.getHttpServer()) + .post("/api/v1/auth/logout") + .set("Cookie", cookie) + .expect(403); await request(app.getHttpServer()) .post("/api/v1/auth/token/refresh") .set("Origin", "https://evil.example") diff --git a/apps/api/src/auth/auth.guard.ts b/apps/api/src/auth/auth.guard.ts index 98b1809..6046f35 100644 --- a/apps/api/src/auth/auth.guard.ts +++ b/apps/api/src/auth/auth.guard.ts @@ -33,6 +33,7 @@ export class AuthGuard implements CanActivate { session.revokedAt || session.expiresAt <= new Date() || session.account.status !== "ACTIVE" || + session.tokenVersion !== claims.token_version || session.account.tokenVersion !== claims.token_version ) throw this.denied(); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 27e6894..3f57197 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -7,6 +7,7 @@ import { PrismaService } from "../database/prisma.service.js"; import { RedisService } from "../redis/redis.service.js"; import { codeHmac, + demoSmsCodeEnabled, encryptPhone, envInt, phoneHmac, @@ -110,7 +111,7 @@ export class AuthService { { EX: envInt("SMS_CODE_TTL_SECONDS", 300) }, ); const result: { sent: true; debugCode?: string } = { sent: true }; - if (process.env.NODE_ENV !== "production") result.debugCode = code; + if (demoSmsCodeEnabled()) result.debugCode = code; return result; } async login( @@ -183,6 +184,7 @@ export class AuthService { data: { id, accountId: account.id, + tokenVersion: account.tokenVersion, deviceId, refreshTokenHash: refreshHmac(refresh), tokenFamily: id, @@ -213,7 +215,11 @@ export class AuthService { expiresIn, }; } - async refresh(token: string): Promise { + async refresh( + token: string, + ip: string, + retryAttempt = 0, + ): Promise { if (!this.validRefreshShape(token)) throw this.invalid(); const hash = refreshHmac(token); const observed = await this.prisma.refreshToken.findUnique({ @@ -221,6 +227,29 @@ export class AuthService { include: { session: true }, }); if (!observed) throw this.invalid(); + if (retryAttempt === 0) { + await this.limitAll([ + { + kind: "refresh-token", + parts: [hash], + max: envInt("REFRESH_RATE_LIMIT", 60), + ttl: 60, + }, + { + kind: "refresh-session", + parts: [observed.sessionId], + max: envInt("REFRESH_RATE_LIMIT", 60), + ttl: 60, + }, + { + kind: "refresh-ip", + parts: [phoneHmac(ip)], + max: envInt("REFRESH_RATE_LIMIT", 60), + ttl: 60, + }, + ]); + await this.cleanupExpiredRefreshTokens(observed.sessionId); + } if (observed.status === "USED") return this.revokeReusedFamily(observed.session.tokenFamily); @@ -235,6 +264,8 @@ export class AuthService { if (stored.status === "USED") return { reusedFamily: stored.session.tokenFamily }; const session = stored.session; + if (session.tokenVersion !== session.account.tokenVersion) + return { reusedFamily: session.tokenFamily }; if ( session.revokedAt || session.expiresAt <= new Date() || @@ -285,23 +316,36 @@ export class AuthService { error instanceof Prisma.PrismaClientKnownRequestError && (error.code === "P2034" || error.code === "P2002") ) { - // A serialization/uniqueness loser can observe the winner only after its - // transaction commits. Poll briefly, then revoke outside the failed tx. - for (let attempt = 0; attempt < 5; attempt += 1) { - const raced = await this.prisma.refreshToken.findUnique({ - where: { tokenHash: hash }, - include: { session: true }, - }); - if (raced?.status === "USED") - return this.revokeReusedFamily(raced.session.tokenFamily); + if (retryAttempt < 3) { await new Promise((resolve) => - setTimeout(resolve, 10 * (attempt + 1)), + setTimeout(resolve, 10 * 2 ** retryAttempt), ); + return this.refresh(token, ip, retryAttempt + 1); } + const raced = await this.prisma.refreshToken.findUnique({ + where: { tokenHash: hash }, + include: { session: true }, + }); + if (raced?.status === "USED") + return this.revokeReusedFamily(raced.session.tokenFamily); + throw new DomainException( + ErrorCode.SERVICE_UNAVAILABLE, + "Authentication temporarily unavailable", + HttpStatus.SERVICE_UNAVAILABLE, + ); } throw error; } } + async cleanupExpiredRefreshTokens(sessionId?: string): Promise { + const deleted = await this.prisma.refreshToken.deleteMany({ + where: { + expiresAt: { lt: new Date() }, + ...(sessionId ? { sessionId } : {}), + }, + }); + return deleted.count; + } private async revokeReusedFamily(family: string): Promise { await this.prisma.session.updateMany({ where: { tokenFamily: family }, diff --git a/apps/api/src/auth/token.service.spec.ts b/apps/api/src/auth/token.service.spec.ts new file mode 100644 index 0000000..08a2d90 --- /dev/null +++ b/apps/api/src/auth/token.service.spec.ts @@ -0,0 +1,94 @@ +import { createHmac, randomUUID } from "node:crypto"; +import { beforeAll, describe, expect, it } from "vitest"; +import { TokenService } from "./token.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 = "token-phone-hmac-key-with-at-least-32-bytes"; +process.env.VERIFICATION_CODE_HMAC_KEY = + "token-code-hmac-key-with-at-least-32-bytes!"; +process.env.REFRESH_TOKEN_HMAC_KEY = + "token-refresh-hmac-key-with-at-least-32-bytes"; +process.env.JWT_SECRET = "token-jwt-secret-with-at-least-thirty-two-bytes"; + +const secret = process.env.JWT_SECRET; +const service = new TokenService(); +const base = () => ({ + sub: randomUUID(), + session_id: randomUUID(), + device_id: "device-test-001", + scopes: ["user"], + token_version: 0, + iss: "drift-api", + aud: "drift-web", + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 60, +}); +const sign = (header: unknown, claims: unknown) => { + const h = Buffer.from(JSON.stringify(header)).toString("base64url"); + const p = Buffer.from(JSON.stringify(claims)).toString("base64url"); + const data = `${h}.${p}`; + return `${data}.${createHmac("sha256", secret).update(data).digest("base64url")}`; +}; + +describe("strict access JWT verification", () => { + beforeAll(() => + expect( + service.verifyAccess(sign({ alg: "HS256", typ: "JWT" }, base())), + ).toMatchObject({ scopes: ["user"] }), + ); + + it.each([ + ["wrong typ", { typ: "JOSE", alg: "HS256" }, base()], + ["array payload", { typ: "JWT", alg: "HS256" }, []], + ["bad sub", { typ: "JWT", alg: "HS256" }, { ...base(), sub: "bad" }], + [ + "bad session", + { typ: "JWT", alg: "HS256" }, + { ...base(), session_id: "bad" }, + ], + [ + "bad device", + { typ: "JWT", alg: "HS256" }, + { ...base(), device_id: "bad device!" }, + ], + [ + "unknown scope", + { typ: "JWT", alg: "HS256" }, + { ...base(), scopes: ["admin"] }, + ], + [ + "negative version", + { typ: "JWT", alg: "HS256" }, + { ...base(), token_version: -1 }, + ], + [ + "fractional exp", + { typ: "JWT", alg: "HS256" }, + { ...base(), exp: Date.now() / 1000 + 60.5 }, + ], + [ + "future iat", + { typ: "JWT", alg: "HS256" }, + { ...base(), iat: Math.floor(Date.now() / 1000) + 301 }, + ], + ])("rejects signed malformed claims: %s", (_name, header, claims) => { + expect(() => service.verifyAccess(sign(header, claims))).toThrowError( + /Unauthorized/, + ); + }); + + it.each(["=", "+", "/", "!"])( + "rejects non-base64url or padded segments containing %s", + (bad) => { + const token = sign({ typ: "JWT", alg: "HS256" }, base()); + const parts = token.split("."); + parts[0] += bad; + expect(() => service.verifyAccess(parts.join("."))).toThrowError( + /Unauthorized/, + ); + }, + ); +}); diff --git a/apps/api/src/auth/token.service.ts b/apps/api/src/auth/token.service.ts index 4d2cc1d..6bbc73a 100644 --- a/apps/api/src/auth/token.service.ts +++ b/apps/api/src/auth/token.service.ts @@ -15,6 +15,21 @@ export interface AccessClaims { 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( @@ -39,28 +54,48 @@ export class TokenService { 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 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 = Buffer.from(s, "base64url"); + const actual = decodeCanonical(s); if ( expected.length !== actual.length || !timingSafeEqual(expected, actual) ) throw new Error(); - const claims = JSON.parse( - Buffer.from(p, "base64url").toString(), - ) as AccessClaims; + 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 <= Math.floor(Date.now() / 1000)) + if (claims.exp <= now) throw new DomainException( ErrorCode.AUTH_TOKEN_EXPIRED, "Access token expired", diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 7d6b341..71caed2 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -4,13 +4,13 @@ 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 { validateAuthEnvironment, webOrigin } 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 allowedOrigin = webOrigin(); const adapter = app.getHttpAdapter?.() as { getInstance(): Application } | undefined; adapter diff --git a/prisma/migrations/0004_session_token_version/migration.sql b/prisma/migrations/0004_session_token_version/migration.sql new file mode 100644 index 0000000..5e3c0ea --- /dev/null +++ b/prisma/migrations/0004_session_token_version/migration.sql @@ -0,0 +1,9 @@ +ALTER TABLE "sessions" ADD COLUMN "token_version" INTEGER NOT NULL DEFAULT 0; + +UPDATE "sessions" AS s +SET "token_version" = a."token_version" +FROM "accounts" AS a +WHERE s."account_id" = a."id"; + +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_token_version_nonnegative" + CHECK ("token_version" >= 0); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 04d0993..8b4e8ba 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -155,6 +155,7 @@ model AuthIdentity { model Session { id String @id @default(uuid()) @db.Uuid accountId String @map("account_id") @db.Uuid + tokenVersion Int @default(0) @map("token_version") refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(255) deviceId String? @map("device_id") @db.VarChar(255) expiresAt DateTime @map("expires_at") @db.Timestamptz(3) diff --git a/tests/integration/database.spec.ts b/tests/integration/database.spec.ts index 13e9a8d..b51ca31 100644 --- a/tests/integration/database.spec.ts +++ b/tests/integration/database.spec.ts @@ -93,6 +93,19 @@ describe("database authority constraints", () => { ).toBe(true); }); + it("backfills and requires the session token version snapshot", async () => { + const columns = await prisma.$queryRaw< + Array<{ is_nullable: string; column_default: string | null }> + >` + SELECT is_nullable, column_default FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'sessions' AND column_name = 'token_version'`; + expect(columns).toHaveLength(1); + expect(columns[0]).toMatchObject({ + is_nullable: "NO", + column_default: "0", + }); + }); + it("expresses independent moderation and pool lifecycle state", async () => { const author = await createAccount("states"); const bottle = await prisma.bottle.create({