Skip to content

Commit 822b5d0

Browse files
authored
feat: token rotation, column encryption, HMAC auth, request coalescing (#509, #510, #511, #512) (#538)
Implements four security and performance improvements: Issue #512 — Refresh token rotation (NIST SP 800-63B) - backend/src/auth/token-rotation.ts: opaque 32-byte tokens, SHA-256 hashes in DB, token family tree with replay detection; reusing a rotated token auto-revokes the entire family (theft indicator) - Configurable absolute TTL (30 days) and sliding expiration (7 days) - Redis blacklist for immediate family revocation; falls back to DB - Rate limit: 5 refresh requests/minute per user via Lua atomic counter - Routes: POST /auth/login, /auth/refresh, /auth/revoke, /auth/revoke-all, GET /auth/sessions (session management UI) - Prisma RefreshToken model with family tracking and revocation metadata Issue #511 — Column-level AES-256-GCM encryption for PII - backend/src/encryption/column-encryptor.ts: AES-256-GCM with random IV per value; tenant DEKs derived via HKDF from COLUMN_ENCRYPTION_MASTER_KEY - Deterministic encryption (HMAC-derived IV) for searchable fields (email exact-match lookups) - Key rotation supported via COLUMN_ENCRYPTION_OLD_MASTER_KEY transition - Prisma $extends middleware in encryption/index.ts: transparently encrypts on write, decrypts on read, rewrites WHERE for searchable fields - Applied to User, Payment, SandboxAccount, AuditLog PII columns - Audit log on every decryption; falls back to dev default in non-prod Issue #510 — HMAC-SHA256 server-to-server request signing - backend/src/middleware/hmac-auth.ts: verifies X-Signature, X-Timestamp, X-Nonce; rejects requests outside ±5-minute window and replayed nonces - Nonce dedup via Redis sorted set; falls back to in-memory Map - Multi-key rotation: supports multiple active SigningKey rows per tenant - Backward compatible: HMAC headers optional; API keys still work - packages/sdk/src/auth/hmac.ts: HmacSigner class for client-side signing - Routes: GET/POST/DELETE /developers/signing-keys, POST .../rotate - Prisma SigningKey model with expiry and isActive for rotation overlap Issue #509 — Request coalescing for identical concurrent GET calls - backend/src/middleware/request-coalescer.ts: in-process promise registry collapses concurrent identical GETs into one execution - Key: SHA-256(method + path + query + auth-hash) — per-user isolation - Multi-instance: Redis lock + result cache for cross-node coalescing - Per-endpoint configuration (resultTtlMs, timeoutMs, enable/disable) - Error propagation: all waiters receive the same error on failure - Metrics: coalescing hit rate, average wait time via GET /coalesce/metrics - Default opt-in: /api/v1/catalog, /api/v1/gas, /api/v1/pool/metrics Closes #509 Closes #510 Closes #511 Closes #512
1 parent 1f0a353 commit 822b5d0

14 files changed

Lines changed: 1647 additions & 1 deletion

File tree

backend/prisma/schema.prisma

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,3 +648,43 @@ model ApiVersionEndpoint {
648648
@@map("api_version_endpoints")
649649
}
650650

651+
// ─── Token Rotation Models (#512) ─────────────────────────────────────────────
652+
653+
model RefreshToken {
654+
id String @id @default(uuid())
655+
tokenHash String @unique @map("token_hash")
656+
familyId String @map("family_id")
657+
userId String @map("user_id")
658+
tenantId String @map("tenant_id")
659+
replacedById String? @map("replaced_by_id")
660+
revoked Boolean @default(false)
661+
revokedAt DateTime? @map("revoked_at")
662+
revokeReason String? @map("revoke_reason")
663+
absoluteExpiresAt DateTime @map("absolute_expires_at")
664+
slidingExpiresAt DateTime @map("sliding_expires_at")
665+
createdAt DateTime @default(now()) @map("created_at")
666+
lastUsedAt DateTime @default(now()) @map("last_used_at")
667+
668+
@@index([familyId])
669+
@@index([userId, tenantId])
670+
@@index([revoked, slidingExpiresAt])
671+
@@map("refresh_tokens")
672+
}
673+
674+
// ─── HMAC Signing Key Models (#510) ───────────────────────────────────────────
675+
676+
model SigningKey {
677+
id String @id @default(uuid())
678+
tenantId String @map("tenant_id")
679+
keyId String @unique @map("key_id")
680+
secretHash String @map("secret_hash")
681+
description String?
682+
isActive Boolean @default(true) @map("is_active")
683+
createdAt DateTime @default(now()) @map("created_at")
684+
revokedAt DateTime? @map("revoked_at")
685+
expiresAt DateTime? @map("expires_at")
686+
687+
@@index([tenantId, isActive])
688+
@@map("signing_keys")
689+
}
690+

backend/src/auth/token-rotation.ts

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
// Token rotation service — Issue #512
2+
// Implements NIST SP 800-63B-compliant refresh token rotation:
3+
// - Opaque 32-byte refresh tokens, only SHA-256 hashes stored in DB
4+
// - Token family tracking; reuse of a rotated token revokes the entire family
5+
// - Absolute TTL (configurable, default 30 days) + sliding expiration (default 7 days)
6+
// - Redis blacklist for immediate family revocation
7+
8+
import { randomBytes, createHash } from 'node:crypto';
9+
import { prisma } from '../lib/prisma.js';
10+
import { auditService } from '../services/auditService.js';
11+
import { getSharedRateLimitRedis } from '../config/rate-limit-redis.js';
12+
13+
// ---------------------------------------------------------------------------
14+
// Configuration
15+
// ---------------------------------------------------------------------------
16+
17+
export interface TokenRotationConfig {
18+
absoluteTtlMs: number;
19+
slidingTtlMs: number;
20+
}
21+
22+
const DEFAULT_CONFIG: TokenRotationConfig = {
23+
absoluteTtlMs: 30 * 24 * 60 * 60 * 1000, // 30 days
24+
slidingTtlMs: 7 * 24 * 60 * 60 * 1000, // 7 days inactivity
25+
};
26+
27+
// ---------------------------------------------------------------------------
28+
// Helpers
29+
// ---------------------------------------------------------------------------
30+
31+
function hashToken(raw: string): string {
32+
return createHash('sha256').update(raw).digest('hex');
33+
}
34+
35+
function generateRawToken(): string {
36+
return randomBytes(32).toString('hex');
37+
}
38+
39+
const FAMILY_BLACKLIST_PREFIX = 'rt:revoked-family:';
40+
41+
async function isRevokedFamily(familyId: string): Promise<boolean> {
42+
try {
43+
const redis = await getSharedRateLimitRedis();
44+
if (redis) {
45+
const val = await redis.get(`${FAMILY_BLACKLIST_PREFIX}${familyId}`);
46+
return val !== null;
47+
}
48+
} catch { /* fall through to DB check */ }
49+
// DB fallback: check if any token in family is revoked with reason 'family_revoked'
50+
const count = await prisma.refreshToken.count({
51+
where: { familyId, revokeReason: 'family_revoked', revoked: true },
52+
});
53+
return count > 0;
54+
}
55+
56+
async function revokeFamily(familyId: string, reason: string): Promise<void> {
57+
const absoluteTtlSec = Math.ceil(DEFAULT_CONFIG.absoluteTtlMs / 1000);
58+
try {
59+
const redis = await getSharedRateLimitRedis();
60+
if (redis) {
61+
await redis.set(
62+
`${FAMILY_BLACKLIST_PREFIX}${familyId}`,
63+
reason,
64+
'EX',
65+
absoluteTtlSec,
66+
);
67+
}
68+
} catch { /* continue to DB update */ }
69+
70+
await prisma.refreshToken.updateMany({
71+
where: { familyId, revoked: false },
72+
data: { revoked: true, revokedAt: new Date(), revokeReason: 'family_revoked' },
73+
});
74+
}
75+
76+
// ---------------------------------------------------------------------------
77+
// Public API
78+
// ---------------------------------------------------------------------------
79+
80+
export interface IssuedTokens {
81+
accessToken: string;
82+
refreshToken: string;
83+
refreshTokenExpiresAt: Date;
84+
}
85+
86+
/** Issue the first token pair when a user authenticates. */
87+
export async function issueTokenFamily(
88+
userId: string,
89+
tenantId: string,
90+
config: TokenRotationConfig = DEFAULT_CONFIG,
91+
): Promise<IssuedTokens> {
92+
const familyId = randomBytes(16).toString('hex');
93+
const rawRefresh = generateRawToken();
94+
const now = new Date();
95+
const absoluteExpiresAt = new Date(now.getTime() + config.absoluteTtlMs);
96+
const slidingExpiresAt = new Date(now.getTime() + config.slidingTtlMs);
97+
98+
await prisma.refreshToken.create({
99+
data: {
100+
tokenHash: hashToken(rawRefresh),
101+
familyId,
102+
userId,
103+
tenantId,
104+
absoluteExpiresAt,
105+
slidingExpiresAt,
106+
lastUsedAt: now,
107+
},
108+
});
109+
110+
void auditService.logAction({
111+
userId,
112+
action: 'token.family_issued',
113+
resource: 'refresh_token',
114+
details: { familyId, tenantId },
115+
});
116+
117+
return {
118+
accessToken: `at_${randomBytes(32).toString('hex')}`,
119+
refreshToken: rawRefresh,
120+
refreshTokenExpiresAt: slidingExpiresAt,
121+
};
122+
}
123+
124+
export interface RotateResult {
125+
ok: true;
126+
accessToken: string;
127+
refreshToken: string;
128+
refreshTokenExpiresAt: Date;
129+
}
130+
131+
export interface RotateError {
132+
ok: false;
133+
reason: 'not_found' | 'revoked' | 'expired' | 'family_compromised';
134+
}
135+
136+
/** Rotate a refresh token. Issues a new pair and invalidates the old token. */
137+
export async function rotateRefreshToken(
138+
rawToken: string,
139+
config: TokenRotationConfig = DEFAULT_CONFIG,
140+
): Promise<RotateResult | RotateError> {
141+
const tokenHash = hashToken(rawToken);
142+
const now = new Date();
143+
144+
const existing = await prisma.refreshToken.findUnique({ where: { tokenHash } });
145+
146+
if (!existing) {
147+
return { ok: false, reason: 'not_found' };
148+
}
149+
150+
if (existing.revoked) {
151+
// A rotated token was reused — this signals token theft. Revoke entire family.
152+
await revokeFamily(existing.familyId, 'replay_detected');
153+
void auditService.logAction({
154+
userId: existing.userId,
155+
action: 'token.family_revoked',
156+
resource: 'refresh_token',
157+
details: { familyId: existing.familyId, reason: 'replay_detected', tenantId: existing.tenantId },
158+
});
159+
return { ok: false, reason: 'family_compromised' };
160+
}
161+
162+
// Check Redis blacklist first (fast path)
163+
if (await isRevokedFamily(existing.familyId)) {
164+
return { ok: false, reason: 'family_compromised' };
165+
}
166+
167+
if (now > existing.absoluteExpiresAt || now > existing.slidingExpiresAt) {
168+
await prisma.refreshToken.update({
169+
where: { id: existing.id },
170+
data: { revoked: true, revokedAt: now, revokeReason: 'expired' },
171+
});
172+
return { ok: false, reason: 'expired' };
173+
}
174+
175+
// Issue new token in same family
176+
const rawNew = generateRawToken();
177+
const slidingExpiresAt = new Date(now.getTime() + config.slidingTtlMs);
178+
179+
const [newToken] = await prisma.$transaction([
180+
prisma.refreshToken.create({
181+
data: {
182+
tokenHash: hashToken(rawNew),
183+
familyId: existing.familyId,
184+
userId: existing.userId,
185+
tenantId: existing.tenantId,
186+
absoluteExpiresAt: existing.absoluteExpiresAt,
187+
slidingExpiresAt,
188+
lastUsedAt: now,
189+
},
190+
}),
191+
prisma.refreshToken.update({
192+
where: { id: existing.id },
193+
data: { revoked: true, revokedAt: now, revokeReason: 'rotated' },
194+
}),
195+
]);
196+
197+
void auditService.logAction({
198+
userId: existing.userId,
199+
action: 'token.rotated',
200+
resource: 'refresh_token',
201+
details: { familyId: existing.familyId, newTokenId: newToken.id, tenantId: existing.tenantId },
202+
});
203+
204+
return {
205+
ok: true,
206+
accessToken: `at_${randomBytes(32).toString('hex')}`,
207+
refreshToken: rawNew,
208+
refreshTokenExpiresAt: slidingExpiresAt,
209+
};
210+
}
211+
212+
/** Revoke a specific token by its raw value. */
213+
export async function revokeToken(rawToken: string, userId?: string): Promise<boolean> {
214+
const tokenHash = hashToken(rawToken);
215+
const token = await prisma.refreshToken.findUnique({ where: { tokenHash } });
216+
if (!token || token.revoked) return false;
217+
218+
await prisma.refreshToken.update({
219+
where: { tokenHash },
220+
data: { revoked: true, revokedAt: new Date(), revokeReason: 'manual_revocation' },
221+
});
222+
223+
void auditService.logAction({
224+
userId: userId ?? token.userId,
225+
action: 'token.revoked',
226+
resource: 'refresh_token',
227+
details: { familyId: token.familyId, tenantId: token.tenantId },
228+
});
229+
230+
return true;
231+
}
232+
233+
/** Revoke all token families for a user (e.g., "sign out everywhere"). */
234+
export async function revokeAllUserTokens(userId: string, tenantId: string): Promise<number> {
235+
// Get unique families to blacklist in Redis
236+
const families = await prisma.refreshToken.findMany({
237+
where: { userId, tenantId, revoked: false },
238+
select: { familyId: true },
239+
distinct: ['familyId'],
240+
});
241+
242+
for (const { familyId } of families) {
243+
await revokeFamily(familyId, 'sign_out_all');
244+
}
245+
246+
const result = await prisma.refreshToken.updateMany({
247+
where: { userId, tenantId, revoked: false },
248+
data: { revoked: true, revokedAt: new Date(), revokeReason: 'sign_out_all' },
249+
});
250+
251+
void auditService.logAction({
252+
userId,
253+
action: 'token.revoke_all',
254+
resource: 'refresh_token',
255+
details: { tenantId, count: result.count },
256+
});
257+
258+
return result.count;
259+
}
260+
261+
/** List active token families for a user (for session management UI). */
262+
export async function listUserTokenFamilies(userId: string, tenantId: string) {
263+
const tokens = await prisma.refreshToken.findMany({
264+
where: { userId, tenantId, revoked: false },
265+
select: {
266+
familyId: true,
267+
createdAt: true,
268+
lastUsedAt: true,
269+
absoluteExpiresAt: true,
270+
slidingExpiresAt: true,
271+
},
272+
orderBy: { lastUsedAt: 'desc' },
273+
});
274+
275+
// Deduplicate by familyId (take most recent per family)
276+
const seen = new Set<string>();
277+
return tokens.filter(t => {
278+
if (seen.has(t.familyId)) return false;
279+
seen.add(t.familyId);
280+
return true;
281+
});
282+
}
283+
284+
/** Prune expired tokens from the DB (run periodically). */
285+
export async function pruneExpiredTokens(): Promise<number> {
286+
const now = new Date();
287+
const result = await prisma.refreshToken.deleteMany({
288+
where: {
289+
OR: [
290+
{ absoluteExpiresAt: { lt: now } },
291+
{ slidingExpiresAt: { lt: now } },
292+
],
293+
},
294+
});
295+
return result.count;
296+
}

0 commit comments

Comments
 (0)