forked from veridatum-labs/earnproof-backend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsession.service.ts
More file actions
304 lines (269 loc) · 9.28 KB
/
Copy pathsession.service.ts
File metadata and controls
304 lines (269 loc) · 9.28 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
import { Injectable, UnauthorizedException } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
import { ConfigService } from "@nestjs/config";
import { randomBytes } from "crypto";
import { PrismaService } from "../database/prisma.service";
import { sha256 } from "../common/crypto/hash";
import { AuthenticatedUser } from "./auth.types";
import { Clock, SystemClock } from "../common/time/clock";
/** Default session TTL: 12 hours in seconds. */
const DEFAULT_TTL_SECONDS = 60 * 60 * 12;
type SessionLookup = {
id: string;
userId: string;
expiresAt: Date;
revokedAt: Date | null;
};
export type SessionIdentity = {
sessionId: string;
userId: string;
};
/**
* How the opaque token is structured (internal only).
*
* Format: `<sessionId>.<32-byte-random-hex>`
*
* The full string is what we hand to the client as a Bearer token.
* We store only `sha256(<fullToken>)` in the database — the raw token
* is never persisted or logged.
*/
@Injectable()
export class SessionService {
private readonly sessionTtlSeconds: number;
constructor(
private readonly prisma: PrismaService,
configService: ConfigService,
// Defaults to the real system clock so every existing call site (Nest's
// DI container, and every test that constructs SessionService directly)
// keeps working unchanged; pass a FixedClock (test/time/fixed-clock.ts)
// to control "now" deterministically in boundary tests.
private readonly clock: Clock = new SystemClock(),
) {
// Re-use the existing SESSION_SECRET env var to confirm the config is
// present; actual token confidentiality comes from the random bytes,
// not from HMAC signing.
configService.getOrThrow<string>("sessionSecret");
this.sessionTtlSeconds = DEFAULT_TTL_SECONDS;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Create a new persisted session for `user` and return the opaque token.
* The token is never stored — only its SHA-256 hash is persisted.
*/
async create(
user: AuthenticatedUser,
ttlSeconds = this.sessionTtlSeconds,
): Promise<{ token: string; sessionId: string; expiresAt: Date }> {
const { token, tokenHash, sessionId } = this.generateToken();
const expiresAt = new Date(this.clock.nowMs() + ttlSeconds * 1000);
await this.prisma.authSession.create({
data: {
id: sessionId,
tokenHash,
userId: user.id,
expiresAt,
},
});
return { token, sessionId, expiresAt };
}
/**
* Validate a Bearer token string.
*
* Rejects:
* - malformed tokens (wrong format)
* - tokens whose session row is missing
* - expired sessions (`expiresAt` in the past)
* - revoked sessions (`revokedAt` is set)
*
* On success updates `lastUsedAt` and returns the session id + userId.
*/
async validate(
token: string,
): Promise<{ sessionId: string; userId: string }> {
const tokenHash = this.hashToken(token);
if (!tokenHash) {
throw new UnauthorizedException("Malformed session token");
}
const session = await this.findSessionByHash(tokenHash);
if (!session) {
throw new UnauthorizedException("Session not found");
}
if (session.revokedAt !== null) {
throw new UnauthorizedException("Session has been revoked");
}
if (session.expiresAt <= this.clock.now()) {
throw new UnauthorizedException("Session has expired");
}
// Fire-and-forget lastUsedAt update — failure is non-fatal.
this.prisma.authSession
.update({
where: { id: session.id },
data: { lastUsedAt: this.clock.now() },
})
.catch(() => {
// Deliberately swallowed: a failed timestamp update must not break
// in-flight requests.
});
return { sessionId: session.id, userId: session.userId };
}
/**
* Resolve a live persisted session without mutating `lastUsedAt` or throwing.
* This is for soft-auth consumers such as global rate limiting; route guards
* still own authentication enforcement.
*/
async tryIdentify(token: string): Promise<SessionIdentity | null> {
const tokenHash = this.hashToken(token);
if (!tokenHash) return null;
try {
const session = await this.findSessionByHash(tokenHash);
if (!session) return null;
if (session.revokedAt !== null) return null;
if (session.expiresAt <= this.clock.now()) return null;
return { sessionId: session.id, userId: session.userId };
} catch {
return null;
}
}
/**
* Revoke a single session by its database id.
* Idempotent — revoking an already-revoked session is a no-op.
*
* @param sessionId The AuthSession.id (NOT the raw token).
*/
async revoke(sessionId: string): Promise<void> {
await this.prisma.authSession.updateMany({
where: {
id: sessionId,
revokedAt: null, // idempotent guard
},
data: { revokedAt: this.clock.now() },
});
}
/**
* Rotate a session: atomically revoke the current session and issue a fresh
* one with a new token. The old session's `rotatedToId` is set to the new
* session id, making the rotation chain queryable.
*
* If `sessionId` is already revoked, throws `UnauthorizedException` to
* prevent reuse of rotated credentials.
*
* @returns The new opaque token and its metadata.
*/
async rotate(
sessionId: string,
user: AuthenticatedUser,
ttlSeconds = this.sessionTtlSeconds,
): Promise<{ token: string; sessionId: string; expiresAt: Date }> {
const { token, tokenHash, sessionId: newSessionId } = this.generateToken();
const expiresAt = new Date(this.clock.nowMs() + ttlSeconds * 1000);
await this.prisma.$transaction(async (transaction: Prisma.TransactionClient) => {
const existing = await transaction.authSession.findUnique({
where: { id: sessionId },
select: { userId: true, revokedAt: true, expiresAt: true },
});
if (!existing || existing.userId !== user.id) {
throw new UnauthorizedException("Session not found");
}
if (existing.revokedAt !== null) {
throw new UnauthorizedException(
"Cannot rotate an already-revoked session",
);
}
if (existing.expiresAt <= this.clock.now()) {
throw new UnauthorizedException("Session has expired");
}
await transaction.authSession.create({
data: {
id: newSessionId,
tokenHash,
userId: user.id,
expiresAt,
},
});
const revoked = await transaction.authSession.updateMany({
where: { id: sessionId, userId: user.id, revokedAt: null },
data: {
revokedAt: this.clock.now(),
rotatedToId: newSessionId,
},
});
if (revoked.count !== 1) {
throw new UnauthorizedException(
"Cannot rotate an already-revoked session",
);
}
});
return { token, sessionId: newSessionId, expiresAt };
}
/**
* Revoke all active sessions for a user (e.g. "logout everywhere").
*/
async revokeAll(userId: string): Promise<void> {
await this.prisma.authSession.updateMany({
where: {
userId,
revokedAt: null,
},
data: { revokedAt: this.clock.now() },
});
}
/**
* Delete session rows that expired before `olderThan` (defaults to now).
* Intended to be called by a scheduled cleanup job.
*
* @returns Number of rows deleted.
*/
async deleteExpired(olderThan: Date = this.clock.now()): Promise<number> {
const result = await this.prisma.authSession.deleteMany({
where: { expiresAt: { lt: olderThan } },
});
return result.count;
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
/**
* Generate a cryptographically random opaque token and its SHA-256 hash.
*
* Token format: `<cuid-style-id>.<32-random-bytes-hex>`
* The id segment is also used as the database primary key so we avoid a
* round-trip SELECT after INSERT.
*/
private generateToken(): {
token: string;
tokenHash: string;
sessionId: string;
} {
// 32 random bytes → 64-char hex string (256 bits of entropy)
const secret = randomBytes(32).toString("hex");
// Use a separate random id segment so the session id alone does not allow
// constructing a valid token (the secret part is still required).
const sessionId = randomBytes(12).toString("base64url");
const token = `${sessionId}.${secret}`;
const tokenHash = sha256(token);
return { token, tokenHash, sessionId };
}
/**
* Hash an arbitrary token string for database lookup.
* Returns `null` for obviously malformed inputs (missing `.` separator).
*/
private hashToken(token: string): string | null {
if (!/^[A-Za-z0-9_-]{16}\.[a-f0-9]{64}$/.test(token)) {
return null;
}
return sha256(token);
}
private findSessionByHash(tokenHash: string): Promise<SessionLookup | null> {
return this.prisma.authSession.findUnique({
where: { tokenHash },
select: {
id: true,
userId: true,
expiresAt: true,
revokedAt: true,
},
});
}
}