Repository navigation
Expand file tree
/
Copy pathsessionService.ts
More file actions
423 lines (348 loc) · 13.7 KB
/
Copy pathsessionService.ts
File metadata and controls
423 lines (348 loc) · 13.7 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
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the Apache License, Version 2.0
* See LICENSE file in the project root for full license information
*/
import { timingSafeEqual } from 'crypto';
import { importSPKI, jwtVerify } from 'jose';
import { Op } from 'sequelize';
import { createRefreshTokenLookup } from '../lib/token.js';
import { Session } from '../models/sessions.js';
import { User } from '../models/users.js';
import getLogger from '../utils/logger.js';
import { getPublicKeyByKid } from '../utils/signingKeyStore.js';
import { decoyPrincipalAsUser, decoyPrincipalForSubject } from './decoyPrincipal.js';
import { resolveCurrentSession } from './oidcAuthContext.js';
import { isRevokedByCutoff, PARENT_SESSION_ENDED } from './sessionRevocation.js';
const logger = getLogger('sessionService');
export type AuthTokenType = 'ephemeral' | 'access';
const ISSUER = process.env.ISSUER!;
const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
export interface ValidatedAccessToken {
userId: string;
sessionId: string;
roles: string[];
organizationId: string | null;
}
/**
* Verifies a token this server signed, and that it is the kind the caller expects.
*
* `either` exists for one caller, misuse detection, which needs to learn which kind a
* token presented on the wrong route was. Everything that authorizes on a token names
* the kind it accepts.
*/
export async function verifyJwtWithKid(token: string, expectedType: AuthTokenType | 'either') {
try {
const { payload } = await jwtVerify(
token,
async (header) => {
const { kid } = header;
if (!kid) throw new Error(`Missing kid in JWT header`);
const publicKeyPem = await getPublicKeyByKid(kid);
if (!publicKeyPem) throw new Error(`No public key for kid ${kid}`);
return importSPKI(publicKeyPem, 'RS256');
},
{
issuer: ISSUER,
audience: ISSUER,
algorithms: ['RS256'],
},
);
if (payload.typ !== 'access' && payload.typ !== 'ephemeral') {
logger.warn(`JWT has no recognised typ: '${payload.typ}'`);
return null;
}
if (expectedType !== 'either' && payload.typ !== expectedType) {
logger.warn(`JWT typ mismatch: expected '${expectedType}', got '${payload.typ}'`);
return null;
}
if (payload.typ === 'access') {
if (!payload.sub) {
logger.warn('Access token missing sub');
return null;
}
if (!payload.sid) {
logger.warn('Access token missing sid');
return null;
}
}
if (payload.typ === 'ephemeral') {
if (!payload.sub) {
logger.warn('Ephemeral JWT missing sub');
return null;
}
}
return payload;
} catch (err) {
logger.warn('JWT verification failed:', err);
return null;
}
}
export async function revokeSessionChain(session: Session, reason = 'refresh_token_reuse') {
const now = new Date();
const seen = new Set<string>();
let current: Session | null = session;
while (current && !seen.has(current.id)) {
seen.add(current.id);
current.revokedAt = now;
current.revokedReason = reason;
await current.save();
if (!current.replacedBySessionId) break;
current = await Session.findByPk(current.replacedBySessionId);
}
await revokeOrphanedChildSessions(session);
}
export async function hardRevokeSession(session: Session, reason = 'manual_revoke') {
session.revokedAt = new Date();
session.revokedReason = reason;
await session.save();
await revokeOrphanedChildSessions(session);
}
/**
* The cascade that follows a session ending, for a user who holds exchanged sessions (ADR
* 0003, X4). A child records the row its parent was on when it was exchanged, and the parent
* rotates on from there, so which children just lost their parent is answered the same way
* validation answers it: by following each child's link forward. This keeps session lists
* accurate. It is not what ends a child, which is the check in `validateSessionRecord`, so a
* failure here is logged rather than failing the revoke that caused it.
*/
export async function revokeOrphanedChildSessions(
session: Pick<Session, 'userId' | 'parentSessionId'>,
) {
if (session.parentSessionId) return;
try {
const children = await Session.findAll({
where: {
userId: session.userId,
parentSessionId: { [Op.ne]: null },
revokedAt: null,
replacedBySessionId: null,
},
});
for (const child of children) {
if (await resolveCurrentSession(child.parentSessionId as string)) continue;
await Session.update(
{ revokedAt: new Date(), revokedReason: PARENT_SESSION_ENDED },
{ where: { id: child.id, revokedAt: null } },
);
}
} catch (error) {
logger.error(`Could not revoke the child sessions of a session that ended: ${error}`);
}
}
/**
* Points a session at its replacement, but only while nothing else has claimed it.
*
* Rotation reads the session, checks it has not been rotated, creates the replacement and
* writes this link, in four statements with nothing holding the answer still between
* them. Two refreshes carrying the same token therefore both pass the check and both
* write the link, and the second write wins: two working refresh tokens exist, and one
* replacement is reachable from nothing, so the chain revocation that reuse detection
* triggers walks straight past it. That is the case reuse detection exists for.
*
* The condition in the `where` is what decides a single winner, in one statement the
* database serialises. No affected rows means another rotation got there first, which is
* the same thing the reuse check answers `401` to.
*/
export async function claimSessionRotation(
session: Session,
replacementSessionId: string,
): Promise<boolean> {
const [claimed] = await Session.update(
{ replacedBySessionId: replacementSessionId },
{ where: { id: session.id, replacedBySessionId: null, revokedAt: null } },
);
if (claimed === 0) {
return false;
}
session.replacedBySessionId = replacementSessionId;
return true;
}
export async function validateAccessToken(token: string): Promise<ValidatedAccessToken | null> {
const payload = await verifyJwtWithKid(token, 'access');
if (!payload) return null;
const { sub: userId, sid: sessionId } = payload;
if (typeof userId !== 'string' || typeof sessionId !== 'string') return null;
return {
userId,
sessionId,
roles: Array.isArray(payload.roles)
? payload.roles.filter((role): role is string => typeof role === 'string')
: [],
organizationId: typeof payload.org_id === 'string' ? payload.org_id : null,
};
}
function safeEqual(left: string, right: string | null) {
if (!right) return false;
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) return false;
return timingSafeEqual(leftBuffer, rightBuffer);
}
export async function findRefreshSessionByToken(
refreshToken: string,
now = new Date(),
): Promise<Session | null> {
const refreshTokenLookup = createRefreshTokenLookup(refreshToken);
const session = await Session.findOne({
where: {
revokedAt: null,
expiresAt: { [Op.gt]: now },
idleExpiresAt: { [Op.gt]: now },
refreshTokenLookup,
},
});
if (!session) return null;
// Re-checked outside the query. The database found the row by this value, but the
// comparison there is the driver's, and this is the one that decides whether the
// presented token is the session's. Constant time, so a near miss cannot be
// distinguished from a far one by how long the answer takes.
if (!safeEqual(refreshTokenLookup, session.refreshTokenLookup)) {
logger.warn('Refresh token lookup matched a session whose fingerprint did not verify');
return null;
}
return session;
}
export type RefreshRefusalReason = 'absolute_lifetime_reached' | 'idle_timeout';
/**
* Why a refresh token that `findRefreshSessionByToken` rejected no longer works, when the
* answer is that its session ran out of time. Only used to make the audit trail say so,
* never to accept the token.
*/
export async function classifyExpiredRefreshToken(
refreshToken: string,
now = new Date(),
): Promise<{ reason: RefreshRefusalReason; session: Session } | null> {
const refreshTokenLookup = createRefreshTokenLookup(refreshToken);
const session = await Session.findOne({
where: { revokedAt: null, replacedBySessionId: null, refreshTokenLookup },
});
if (!session || !safeEqual(refreshTokenLookup, session.refreshTokenLookup)) return null;
if (session.expiresAt <= now) return { reason: 'absolute_lifetime_reached', session };
if (session.idleExpiresAt <= now) return { reason: 'idle_timeout', session };
return null;
}
export async function validateSessionRecord(sessionId: string) {
const session = await Session.findByPk(sessionId);
if (!session) return null;
const now = new Date();
if (session.revokedAt) return null;
// A rotated session is simply no longer current, so its access token stops being
// accepted. It is not reuse: the previous access token stays valid until it expires,
// so an in-flight request that raced the refresh, or a second tab, legitimately
// arrives holding it. Revoking the chain here followed replacedBySessionId forward
// into the session that was just issued and signed the user out everywhere. Refresh
// token reuse is still detected, in refreshSession, where the reused credential is
// the refresh token itself.
if (session.replacedBySessionId) return null;
if (session.expiresAt < now) return null;
if (session.idleExpiresAt < now) return null;
// Catches a session that a revoke-all missed because a refresh created it mid-revoke.
const owner = await User.findOne({
where: { id: session.userId },
attributes: ['id', 'sessionsRevokedBefore'],
});
if (!owner || isRevokedByCutoff(session, owner.sessionsRevokedBefore)) return null;
// An exchanged session ends with its parent whether or not a revoke's cascade reached it,
// and the parent also ends without any revoke when it expires or idles out (ADR 0003, X4).
if (session.parentSessionId && !(await resolveCurrentSession(session.parentSessionId))) {
return null;
}
return session;
}
export async function getUserFromSession(session: Session) {
const user = await User.findOne({
where: { id: session.userId, revoked: false },
});
return user ?? null;
}
export interface ValidatedBearerToken {
user: User;
sessionId?: string;
organizationId?: string | null;
decoy?: boolean;
/** The ephemeral token's `jti`, absent on tokens minted before it was set. */
attemptId?: string;
/** An access session a recovery issued, restricted until a new factor is enrolled. */
recovery?: boolean;
/** The step-up state of a session exchanged from a device grant (ADR 0003, X7). */
deviceChildSession?: Pick<Session, 'stepUpVerifiedAt' | 'stepUpMethod'>;
}
export async function validateEphemeralToken(token: string): Promise<ValidatedBearerToken | null> {
const payload = await verifyJwtWithKid(token, 'ephemeral');
if (!payload || typeof payload.sub !== 'string') {
return null;
}
const attempt = typeof payload.jti === 'string' ? { attemptId: payload.jti } : {};
const user = await User.findOne({
where: { id: payload.sub, revoked: false },
});
if (user) {
return { user, ...attempt };
}
// The signature, issuer, audience, type and expiry have all already been checked, so
// this token was minted here. A subject that resolves to no row is therefore the
// decoy `/login` issued for an identifier with no usable account, and the request
// continues as that fiction rather than being rejected. Rejecting here is what used
// to move the oracle one request later.
//
// A token whose user was deleted or revoked mid-flow lands here too, and is likewise
// answered as a decoy rather than with a distinguishable 401.
return {
user: decoyPrincipalAsUser(decoyPrincipalForSubject(payload.sub)),
decoy: true,
...attempt,
};
}
export async function validateBearerToken(
token: string,
expectedType: AuthTokenType = 'access',
): Promise<ValidatedBearerToken | null> {
if (expectedType === 'ephemeral') {
return validateEphemeralToken(token);
}
const accessToken = await validateAccessToken(token);
if (!accessToken) {
return null;
}
const session = await validateSessionRecord(accessToken.sessionId);
if (!session) {
return null;
}
if (session.userId !== accessToken.userId) {
logger.warn('Access token subject did not match the session owner');
return null;
}
const user = await getUserFromSession(session);
return user
? {
user,
sessionId: accessToken.sessionId,
organizationId: accessToken.organizationId,
// Read from the session rather than the token: enrolling a factor clears it on
// the row, which takes effect at once, before the client next refreshes.
recovery: session.recovery === true,
...(session.exchangeGrantType === DEVICE_CODE_GRANT
? {
deviceChildSession: {
stepUpVerifiedAt: session.stepUpVerifiedAt,
stepUpMethod: session.stepUpMethod,
},
}
: {}),
}
: null;
}
/**
* Ends a recovery session's restriction once the user has enrolled a factor of their
* own. The API reads the flag from the row, so this applies to the very next request;
* the access token keeps its `recovery` claim until the client refreshes.
*/
export async function liftRecoveryRestriction(sessionId: string | undefined) {
if (!sessionId) return false;
const [updated] = await Session.update(
{ recovery: false },
{ where: { id: sessionId, recovery: true } },
);
return updated > 0;
}