@thunderid/node 1.0.7 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,50 @@
1
- import { ThunderIDJavaScriptClient, ExtendedAuthorizeRequestUrlParams, IdToken, OIDCEndpoints, Storage, TokenExchangeRequestConfig, TokenResponse, User } from '@thunderid/javascript';
1
+ import { BackchannelLogoutResult, ExtendedAuthorizeRequestUrlParams, IdToken, OIDCEndpoints, Storage, ThunderIDJavaScriptClient, TokenExchangeRequestConfig, TokenResponse, User } from '@thunderid/javascript';
2
2
  import { ThunderIDNodeConfig } from './models/config';
3
- declare class ThunderIDNodeClient<T = ThunderIDNodeConfig> extends ThunderIDJavaScriptClient<T> {
3
+ declare class ThunderIDNodeClient<T extends ThunderIDNodeConfig = ThunderIDNodeConfig> extends ThunderIDJavaScriptClient<T> {
4
4
  private _nodeInstanceId;
5
+ private _handledLogoutTokens;
5
6
  constructor(instanceId?: number);
6
7
  initialize(config: T, storage?: Storage): Promise<boolean>;
7
8
  getInstanceId(): number;
8
9
  signIn(...args: any[]): Promise<TokenResponse>;
9
10
  getSignInUrl(requestConfig?: ExtendedAuthorizeRequestUrlParams, userId?: string): Promise<string>;
10
11
  signOut(...args: any[]): Promise<string>;
12
+ /**
13
+ * Handles an OpenID Connect back-channel logout: validates the logout token the server posted,
14
+ * then ends every local session it names. A token with a `sid` ends the sessions that joined
15
+ * that server session. A token with only a `sub` ends that subject's sessions that began before
16
+ * the token was issued, so a user who has already signed in again stays signed in.
17
+ *
18
+ * Ending a session here is local. The server has already revoked the tokens, so nothing is sent
19
+ * to it. A valid token that names no session held here succeeds with `sessionsEnded: 0`, which
20
+ * keeps a retried notification harmless.
21
+ *
22
+ * @param logoutToken - The raw `logout_token` form parameter.
23
+ * @returns The `sid` and `sub` the token named, when it was issued, and how many sessions were ended.
24
+ * @throws {InvalidLogoutTokenError} When the token is not valid or was already handled.
25
+ */
26
+ handleBackchannelLogout(logoutToken: string): Promise<BackchannelLogoutResult>;
27
+ /**
28
+ * Removes a session's index entries. Called wherever the stored session is removed, so the index
29
+ * does not keep growing: nothing else removes an entry once its session is gone. Sign-out does
30
+ * not remove the stored session, so it keeps the entry: the back-channel logout that follows
31
+ * finds the session through it.
32
+ */
33
+ private forgetSession;
34
+ clearSession(sessionId?: string): void;
35
+ protected clearSessionAsync(sessionId?: string): Promise<void>;
36
+ /**
37
+ * Records a new session against the `sid` and `sub` of its ID token, so a back-channel logout
38
+ * can find it. Sign-in does not depend on it, so a failure here is logged and not raised.
39
+ */
40
+ private indexSession;
41
+ private readSessionBinding;
42
+ /**
43
+ * Refuses a logout token whose `jti` was already handled. Kept in this process only, so it is
44
+ * a best effort across instances; a replay still cannot end a session that began after it.
45
+ */
46
+ private assertNotReplayed;
47
+ private rememberHandled;
11
48
  isSignedIn(userId?: string): Promise<boolean>;
12
49
  getIdToken(userId?: string): Promise<string>;
13
50
  refreshAccessToken(userId?: string): Promise<TokenResponse | User>;
@@ -17,6 +54,11 @@ declare class ThunderIDNodeClient<T = ThunderIDNodeConfig> extends ThunderIDJava
17
54
  getUser(userId?: string): Promise<User>;
18
55
  getOpenIDProviderEndpoints(): Promise<Partial<OIDCEndpoints>>;
19
56
  exchangeToken(config: TokenExchangeRequestConfig, userId?: string): Promise<TokenResponse | Response | User>;
57
+ /**
58
+ * Updates one or more of the signed-in user's credentials (e.g. `password`, or any other
59
+ * attribute the user type schema declares `credential: true`).
60
+ */
61
+ updateUserCredentials(payload: Record<string, string>, userId?: string): Promise<void>;
20
62
  }
21
63
  export default ThunderIDNodeClient;
22
64
  //# sourceMappingURL=ThunderIDNodeClient.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ThunderIDNodeClient.d.ts","sourceRoot":"","sources":["../src/ThunderIDNodeClient.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,yBAAyB,EAEzB,iCAAiC,EACjC,OAAO,EACP,aAAa,EAEb,OAAO,EACP,0BAA0B,EAC1B,aAAa,EACb,IAAI,EACL,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAC,mBAAmB,EAAC,MAAM,iBAAiB,CAAC;AAKpD,cAAM,mBAAmB,CAAC,CAAC,GAAG,mBAAmB,CAAE,SAAQ,yBAAyB,CAAC,CAAC,CAAC;IACrF,OAAO,CAAC,eAAe,CAAK;gBAET,UAAU,SAAI;IAKX,UAAU,CAAC,MAAM,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAMhE,aAAa,IAAI,MAAM;IAIjB,MAAM,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,OAAO,CAAC,aAAa,CAAC;IAsE9C,YAAY,CAChC,aAAa,CAAC,EAAE,iCAAiC,EACjD,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,MAAM,CAAC;IAcI,OAAO,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC;IAexC,UAAU,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAqB7C,UAAU,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAa5C,kBAAkB,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC;IAIlE,iBAAiB,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,OAAO,CAAC;IAI/D,iBAAiB,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAItE,cAAc,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAIhD,OAAO,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvC,0BAA0B,IAAI,OAAO,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC;IAI7D,aAAa,CACjC,MAAM,EAAE,0BAA0B,EAClC,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,aAAa,GAAG,QAAQ,GAAG,IAAI,CAAC;CAG5C;AAED,eAAe,mBAAmB,CAAC"}
1
+ {"version":3,"file":"ThunderIDNodeClient.d.ts","sourceRoot":"","sources":["../src/ThunderIDNodeClient.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,uBAAuB,EACvB,iCAAiC,EACjC,OAAO,EAIP,aAAa,EAGb,OAAO,EAEP,yBAAyB,EACzB,0BAA0B,EAC1B,aAAa,EAEb,IAAI,EACL,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAC,mBAAmB,EAAC,MAAM,iBAAiB,CAAC;AAMpD,cAAM,mBAAmB,CAAC,CAAC,SAAS,mBAAmB,GAAG,mBAAmB,CAAE,SAAQ,yBAAyB,CAAC,CAAC,CAAC;IACjH,OAAO,CAAC,eAAe,CAAK;IAG5B,OAAO,CAAC,oBAAoB,CAAkD;gBAE3D,UAAU,SAAI;IAKX,UAAU,CAAC,MAAM,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAMhE,aAAa,IAAI,MAAM;IAIjB,MAAM,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,OAAO,CAAC,aAAa,CAAC;IAsE9C,YAAY,CAChC,aAAa,CAAC,EAAE,iCAAiC,EACjD,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,MAAM,CAAC;IAcI,OAAO,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC;IAe9D;;;;;;;;;;;;;OAaG;IACU,uBAAuB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,uBAAuB,CAAC;IAsD3F;;;;;OAKG;YACW,aAAa;IAYX,YAAY,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI;cAI7B,iBAAiB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAK7E;;;OAGG;YACW,YAAY;YAYZ,kBAAkB;IAgBhC;;;OAGG;IACH,OAAO,CAAC,iBAAiB;IAezB,OAAO,CAAC,eAAe;IAID,UAAU,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAsB7C,UAAU,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAa5C,kBAAkB,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC;IAIlE,iBAAiB,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,OAAO,CAAC;IAO/D,iBAAiB,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAItE,cAAc,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAIhD,OAAO,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvC,0BAA0B,IAAI,OAAO,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC;IAI7D,aAAa,CACjC,MAAM,EAAE,0BAA0B,EAClC,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,aAAa,GAAG,QAAQ,GAAG,IAAI,CAAC;IAI3C;;;OAGG;IACU,qBAAqB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAapG;AAED,eAAe,mBAAmB,CAAC"}
@@ -88,6 +88,82 @@ var NodeCryptoUtils = class {
88
88
  };
89
89
  var NodeCryptoUtils_default = NodeCryptoUtils;
90
90
 
91
+ //#endregion
92
+ //#region src/utils/SessionIndex.ts
93
+ const SID_INDEX = "backchannel_logout_sid";
94
+ const SUB_INDEX = "backchannel_logout_sub";
95
+ /**
96
+ * Finds local sessions by the server session identifier (`sid`) or the subject (`sub`) of their ID
97
+ * token. The storage adapter cannot list sessions, so a back-channel logout needs this to find the
98
+ * ones a logout token names. It lives in the same store as the sessions themselves.
99
+ */
100
+ var SessionIndex = class {
101
+ storageManager;
102
+ constructor(storageManager) {
103
+ this.storageManager = storageManager;
104
+ }
105
+ /**
106
+ * Records a session that just began.
107
+ */
108
+ async add(sessionId, binding, startedAt) {
109
+ if (binding.sid) {
110
+ const live = await this.dropEndedSessions(await this.read(SID_INDEX, binding.sid));
111
+ await this.write(SID_INDEX, binding.sid, {
112
+ ...live,
113
+ [sessionId]: startedAt
114
+ });
115
+ }
116
+ if (binding.sub) {
117
+ const live = await this.dropEndedSessions(await this.read(SUB_INDEX, binding.sub));
118
+ await this.write(SUB_INDEX, binding.sub, {
119
+ ...live,
120
+ [sessionId]: startedAt
121
+ });
122
+ }
123
+ }
124
+ findBySid(sid) {
125
+ return this.read(SID_INDEX, sid);
126
+ }
127
+ findBySub(sub) {
128
+ return this.read(SUB_INDEX, sub);
129
+ }
130
+ /**
131
+ * Forgets a session that has ended.
132
+ */
133
+ async remove(sessionId, binding) {
134
+ if (binding.sid) await this.removeEntry(SID_INDEX, binding.sid, sessionId);
135
+ if (binding.sub) await this.removeEntry(SUB_INDEX, binding.sub, sessionId);
136
+ }
137
+ async read(index, key) {
138
+ try {
139
+ return await this.storageManager.getCustomData(index, key) ?? {};
140
+ } catch {
141
+ return {};
142
+ }
143
+ }
144
+ async write(index, key, sessions) {
145
+ await this.storageManager.removeCustomData(index, key);
146
+ if (Object.keys(sessions).length > 0) await this.storageManager.setCustomData(index, sessions, key);
147
+ }
148
+ async removeEntry(index, key, sessionId) {
149
+ const sessions = await this.read(index, key);
150
+ if (!(sessionId in sessions)) return;
151
+ delete sessions[sessionId];
152
+ await this.write(index, key, sessions);
153
+ }
154
+ async dropEndedSessions(sessions) {
155
+ const checked = await Promise.all(Object.entries(sessions).map(async ([sessionId, startedAt]) => {
156
+ try {
157
+ return (await this.storageManager.getSessionData(sessionId))?.access_token ? [sessionId, startedAt] : void 0;
158
+ } catch {
159
+ return;
160
+ }
161
+ }));
162
+ return Object.fromEntries(checked.filter((entry) => entry !== void 0));
163
+ }
164
+ };
165
+ var SessionIndex_default = SessionIndex;
166
+
91
167
  //#endregion
92
168
  //#region src/utils/SessionUtils.ts
93
169
  const UUID_VERSION = 4;
@@ -130,6 +206,7 @@ var SessionUtils_default = SessionUtils;
130
206
  //#region src/ThunderIDNodeClient.ts
131
207
  var ThunderIDNodeClient = class extends __thunderid_javascript.ThunderIDJavaScriptClient {
132
208
  _nodeInstanceId = 0;
209
+ _handledLogoutTokens = /* @__PURE__ */ new Map();
133
210
  constructor(instanceId = 0) {
134
211
  super(void 0, new NodeCryptoUtils_default());
135
212
  this._nodeInstanceId = instanceId;
@@ -146,7 +223,7 @@ var ThunderIDNodeClient = class extends __thunderid_javascript.ThunderIDJavaScri
146
223
  return this._nodeInstanceId;
147
224
  }
148
225
  async signIn(...args) {
149
- const [authURLCallback, userId, authorizationCode, sessionState, state, signInConfig] = args;
226
+ const [authURLCallback, userId, authorizationCode, state, signInConfig] = args;
150
227
  if (!userId) return Promise.reject(new __thunderid_javascript.ThunderIDAuthException("NODE-AUTH_CLIENT-SI-NF01", "No user ID was provided.", "Unable to sign in the user as no user ID was provided."));
151
228
  if (await this.isSignedIn(userId)) {
152
229
  const sessionData$1 = await this.getStorageManager().getSessionData(userId);
@@ -173,8 +250,9 @@ var ThunderIDNodeClient = class extends __thunderid_javascript.ThunderIDJavaScri
173
250
  tokenType: ""
174
251
  });
175
252
  }
176
- await this.requestAccessToken(authorizationCode, sessionState ?? "", state, userId);
253
+ await this.requestAccessToken(authorizationCode, state, userId);
177
254
  const sessionData = await this.getStorageManager().getSessionData(userId);
255
+ await this.indexSession(userId, sessionData);
178
256
  return Promise.resolve({
179
257
  accessToken: sessionData.access_token,
180
258
  createdAt: sessionData.created_at,
@@ -196,6 +274,114 @@ var ThunderIDNodeClient = class extends __thunderid_javascript.ThunderIDJavaScri
196
274
  if (!signOutUrl) return Promise.reject(new __thunderid_javascript.ThunderIDAuthException("NODE-AUTH_CLIENT-SO-NF01", "Signing out the user failed.", "Could not obtain the sign-out URL from the server."));
197
275
  return signOutUrl;
198
276
  }
277
+ /**
278
+ * Handles an OpenID Connect back-channel logout: validates the logout token the server posted,
279
+ * then ends every local session it names. A token with a `sid` ends the sessions that joined
280
+ * that server session. A token with only a `sub` ends that subject's sessions that began before
281
+ * the token was issued, so a user who has already signed in again stays signed in.
282
+ *
283
+ * Ending a session here is local. The server has already revoked the tokens, so nothing is sent
284
+ * to it. A valid token that names no session held here succeeds with `sessionsEnded: 0`, which
285
+ * keeps a retried notification harmless.
286
+ *
287
+ * @param logoutToken - The raw `logout_token` form parameter.
288
+ * @returns The `sid` and `sub` the token named, when it was issued, and how many sessions were ended.
289
+ * @throws {InvalidLogoutTokenError} When the token is not valid or was already handled.
290
+ */
291
+ async handleBackchannelLogout(logoutToken) {
292
+ const claims = await this.validateLogoutToken(logoutToken);
293
+ this.assertNotReplayed(claims);
294
+ const index = new SessionIndex_default(this.getStorageManager());
295
+ let sessions;
296
+ if (claims.sid) sessions = await index.findBySid(claims.sid);
297
+ else {
298
+ const tolerance = (await this.getStorageManager().getConfigData())?.tokenValidation?.idToken?.clockTolerance ?? __thunderid_javascript.BackchannelLogoutConstants.DEFAULT_CLOCK_TOLERANCE_SECONDS;
299
+ const issuedBefore = (claims.iat + 1 + tolerance) * 1e3;
300
+ sessions = Object.fromEntries(Object.entries(await index.findBySub(claims.sub)).filter(([, startedAt]) => startedAt < issuedBefore));
301
+ }
302
+ let sessionsEnded = 0;
303
+ await Object.keys(sessions).reduce(async (previous, sessionId) => {
304
+ await previous;
305
+ const stored = await this.readSessionBinding(sessionId);
306
+ if (stored.sid && claims.sid && stored.sid !== claims.sid || stored.sub && claims.sub && stored.sub !== claims.sub) {
307
+ await index.remove(sessionId, {
308
+ sid: claims.sid,
309
+ sub: claims.sub
310
+ });
311
+ return;
312
+ }
313
+ await this.clearSessionAsync(sessionId);
314
+ await index.remove(sessionId, {
315
+ sid: claims.sid,
316
+ sub: claims.sub,
317
+ ...stored
318
+ });
319
+ sessionsEnded += 1;
320
+ }, Promise.resolve());
321
+ this.rememberHandled(claims);
322
+ return {
323
+ issuedAt: claims.iat,
324
+ sessionsEnded,
325
+ sid: claims.sid,
326
+ sub: claims.sub
327
+ };
328
+ }
329
+ /**
330
+ * Removes a session's index entries. Called wherever the stored session is removed, so the index
331
+ * does not keep growing: nothing else removes an entry once its session is gone. Sign-out does
332
+ * not remove the stored session, so it keeps the entry: the back-channel logout that follows
333
+ * finds the session through it.
334
+ */
335
+ async forgetSession(sessionId, sessionData) {
336
+ try {
337
+ const binding = await this.readSessionBinding(sessionId ?? "", sessionData);
338
+ if (binding.sid || binding.sub) await new SessionIndex_default(this.getStorageManager()).remove(sessionId ?? "", binding);
339
+ } catch {
340
+ __thunderid_javascript.logger.warn("Could not remove the session from the back-channel logout index.");
341
+ }
342
+ }
343
+ clearSession(sessionId) {
344
+ this.clearSessionAsync(sessionId);
345
+ }
346
+ async clearSessionAsync(sessionId) {
347
+ await this.forgetSession(sessionId);
348
+ await super.clearSessionAsync(sessionId);
349
+ }
350
+ /**
351
+ * Records a new session against the `sid` and `sub` of its ID token, so a back-channel logout
352
+ * can find it. Sign-in does not depend on it, so a failure here is logged and not raised.
353
+ */
354
+ async indexSession(sessionId, sessionData) {
355
+ try {
356
+ const binding = await this.readSessionBinding(sessionId, sessionData);
357
+ if (binding.sid || binding.sub) await new SessionIndex_default(this.getStorageManager()).add(sessionId, binding, Date.now());
358
+ } catch {
359
+ __thunderid_javascript.logger.warn("Could not index the session for back-channel logout.");
360
+ }
361
+ }
362
+ async readSessionBinding(sessionId, sessionData) {
363
+ const idToken = (sessionData ?? await this.getStorageManager().getSessionData(sessionId))?.id_token;
364
+ if (!idToken) return {};
365
+ const { sid, sub } = await this.decodeJwtToken(idToken);
366
+ return {
367
+ ...typeof sid === "string" && sid ? { sid } : {},
368
+ ...typeof sub === "string" && sub ? { sub } : {}
369
+ };
370
+ }
371
+ /**
372
+ * Refuses a logout token whose `jti` was already handled. Kept in this process only, so it is
373
+ * a best effort across instances; a replay still cannot end a session that began after it.
374
+ */
375
+ assertNotReplayed(claims) {
376
+ const now = Date.now();
377
+ this._handledLogoutTokens.forEach((expiresAt, jti) => {
378
+ if (expiresAt <= now) this._handledLogoutTokens.delete(jti);
379
+ });
380
+ if (this._handledLogoutTokens.has(claims.jti)) throw new __thunderid_javascript.InvalidLogoutTokenError("NODE-AUTH_CLIENT-HBL-IV01", "The logout token was already handled.");
381
+ }
382
+ rememberHandled(claims) {
383
+ this._handledLogoutTokens.set(claims.jti, claims.exp * 1e3);
384
+ }
199
385
  async isSignedIn(userId) {
200
386
  try {
201
387
  if (!await super.isSignedIn(userId)) return false;
@@ -203,6 +389,7 @@ var ThunderIDNodeClient = class extends __thunderid_javascript.ThunderIDJavaScri
203
389
  const sessionData = await sm.getSessionData(userId);
204
390
  if (await SessionUtils_default.validateSession(sessionData)) return true;
205
391
  if (await this.refreshAccessToken(userId)) return true;
392
+ await this.forgetSession(userId, sessionData);
206
393
  await sm.removeSessionData(userId);
207
394
  return false;
208
395
  } catch {
@@ -217,6 +404,7 @@ var ThunderIDNodeClient = class extends __thunderid_javascript.ThunderIDJavaScri
217
404
  return super.refreshAccessToken(userId);
218
405
  }
219
406
  async revokeAccessToken(userId) {
407
+ await this.forgetSession(userId);
220
408
  return super.revokeAccessToken(userId);
221
409
  }
222
410
  async getDecodedIdToken(userId, idToken) {
@@ -234,6 +422,20 @@ var ThunderIDNodeClient = class extends __thunderid_javascript.ThunderIDJavaScri
234
422
  async exchangeToken(config, userId) {
235
423
  return super.exchangeToken(config, userId);
236
424
  }
425
+ /**
426
+ * Updates one or more of the signed-in user's credentials (e.g. `password`, or any other
427
+ * attribute the user type schema declares `credential: true`).
428
+ */
429
+ async updateUserCredentials(payload, userId) {
430
+ const configData = await this.getStorageManager().getConfigData();
431
+ const baseUrl = configData?.baseUrl;
432
+ await (0, __thunderid_javascript.updateMeCredentials)({
433
+ baseUrl,
434
+ headers: { Authorization: `Bearer ${await this.getAccessToken(userId)}` },
435
+ payload,
436
+ url: (0, __thunderid_javascript.resolveResourceEndpoint)("usersMeCredentials", configData)
437
+ });
438
+ }
237
439
  };
238
440
  var ThunderIDNodeClient_default = ThunderIDNodeClient;
239
441
 
@@ -342,6 +544,100 @@ var CookieChunking = class CookieChunking {
342
544
  };
343
545
  var CookieChunking_default = CookieChunking;
344
546
 
547
+ //#endregion
548
+ //#region src/utils/LoggedOutSessions.ts
549
+ /**
550
+ * Keeps logout records in the memory of this process, dropping expired ones as new ones arrive.
551
+ */
552
+ var InMemoryLogoutRecordStore = class {
553
+ records = /* @__PURE__ */ new Map();
554
+ get(key) {
555
+ const record = this.records.get(key);
556
+ if (record && record.expiresAt <= Date.now()) {
557
+ this.records.delete(key);
558
+ return Promise.resolve(void 0);
559
+ }
560
+ return Promise.resolve(record?.value);
561
+ }
562
+ set(key, value, ttlSeconds) {
563
+ const now = Date.now();
564
+ this.records.forEach((record, recordKey) => {
565
+ if (record.expiresAt <= now) this.records.delete(recordKey);
566
+ });
567
+ this.records.set(key, {
568
+ expiresAt: now + ttlSeconds * 1e3,
569
+ value
570
+ });
571
+ return Promise.resolve();
572
+ }
573
+ };
574
+ /**
575
+ * The sessions that back-channel logout has ended, for an SDK whose session lives in a cookie the
576
+ * server cannot delete. The logout handler records each ended session here, and every later read
577
+ * of a session cookie asks whether it matches one.
578
+ *
579
+ * A record by `sid` ends exactly that server session. A record by `sub`, written only when the
580
+ * logout token carried no `sid`, ends that subject's sessions that began before the token was
581
+ * issued, so signing in again afterwards works.
582
+ */
583
+ var LoggedOutSessions = class LoggedOutSessions {
584
+ store;
585
+ constructor(store = new InMemoryLogoutRecordStore()) {
586
+ this.store = store;
587
+ }
588
+ /**
589
+ * Records a logout.
590
+ *
591
+ * @param logout - The `sid`, `sub` and `iat` of the validated logout token, and the clock
592
+ * tolerance its validation used. A logout by `sub` ends the sessions that began up to that
593
+ * long after `iat`: the clocks may differ by that much, and ending a session that began just
594
+ * after the logout is the safer mistake.
595
+ * @param ttlSeconds - The longest a matching session cookie could still be presented.
596
+ */
597
+ async record(logout, ttlSeconds) {
598
+ const expiresAt = Date.now() + ttlSeconds * 1e3;
599
+ if (logout.sid) {
600
+ await this.store.set(LoggedOutSessions.sidKey(logout.sid), JSON.stringify({ expiresAt }), ttlSeconds);
601
+ return;
602
+ }
603
+ if (logout.sub) {
604
+ const existing = await this.read(LoggedOutSessions.subKey(logout.sub));
605
+ const endedAt = Math.max(logout.issuedAt + (logout.clockTolerance ?? __thunderid_javascript.BackchannelLogoutConstants.DEFAULT_CLOCK_TOLERANCE_SECONDS), existing?.endedAt ?? 0);
606
+ await this.store.set(LoggedOutSessions.subKey(logout.sub), JSON.stringify({
607
+ endedAt,
608
+ expiresAt
609
+ }), ttlSeconds);
610
+ }
611
+ }
612
+ /**
613
+ * Reports whether the presented session was ended by a recorded logout.
614
+ */
615
+ async isLoggedOut(session) {
616
+ if (session.sid && await this.read(LoggedOutSessions.sidKey(session.sid))) return true;
617
+ if (!session.sub) return false;
618
+ const bySubject = await this.read(LoggedOutSessions.subKey(session.sub));
619
+ if (bySubject?.endedAt === void 0) return false;
620
+ return session.startedAt === void 0 || session.startedAt <= bySubject.endedAt;
621
+ }
622
+ async read(key) {
623
+ const value = await this.store.get(key);
624
+ if (!value) return;
625
+ try {
626
+ const record = JSON.parse(value);
627
+ return record.expiresAt > Date.now() ? record : void 0;
628
+ } catch {
629
+ return;
630
+ }
631
+ }
632
+ static sidKey(sid) {
633
+ return `thunderid:logout:sid:${encodeURIComponent(sid)}`;
634
+ }
635
+ static subKey(sub) {
636
+ return `thunderid:logout:sub:${encodeURIComponent(sub)}`;
637
+ }
638
+ };
639
+ var LoggedOutSessions_default = LoggedOutSessions;
640
+
345
641
  //#endregion
346
642
  //#region src/utils/generateSessionId.ts
347
643
  const generateSessionId = () => (/* @__PURE__ */ new Date()).getTime().toString(36) + Math.random().toString(36).substring(2);
@@ -368,8 +664,11 @@ if (!globalThis.fetch) {
368
664
  //#endregion
369
665
  exports.CookieChunking = CookieChunking_default;
370
666
  exports.CookieConfig = CookieConfig_default;
667
+ exports.InMemoryLogoutRecordStore = InMemoryLogoutRecordStore;
668
+ exports.LoggedOutSessions = LoggedOutSessions_default;
371
669
  exports.MemoryCacheStore = MemoryCacheStore_default;
372
670
  exports.NodeCryptoUtils = NodeCryptoUtils_default;
671
+ exports.SessionIndex = SessionIndex_default;
373
672
  exports.SessionUtils = SessionUtils_default;
374
673
  exports.ThunderIDNodeClient = ThunderIDNodeClient_default;
375
674
  exports.generateSessionId = generateSessionId_default;
package/dist/index.d.ts CHANGED
@@ -9,6 +9,10 @@ export { default as MemoryCacheStore } from './stores/MemoryCacheStore';
9
9
  export { default as CookieChunking } from './utils/CookieChunking';
10
10
  export { default as NodeCryptoUtils } from './utils/NodeCryptoUtils';
11
11
  export { default as SessionUtils } from './utils/SessionUtils';
12
+ export { default as SessionIndex } from './utils/SessionIndex';
13
+ export { default as LoggedOutSessions, InMemoryLogoutRecordStore } from './utils/LoggedOutSessions';
14
+ export type { LogoutRecordStore, PresentedSession } from './utils/LoggedOutSessions';
15
+ export type { IndexedSessions, SessionBinding } from './utils/SessionIndex';
12
16
  export { default as generateSessionId } from './utils/generateSessionId';
13
17
  export { default as getSessionCookieOptions } from './utils/getSessionCookieOptions';
14
18
  export * from '@thunderid/javascript';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAaA;;GAEG;AAGH,OAAO,EAAC,OAAO,IAAI,mBAAmB,EAAC,MAAM,uBAAuB,CAAC;AAGrE,OAAO,EAAC,OAAO,IAAI,YAAY,EAAC,MAAM,0BAA0B,CAAC;AAGjE,YAAY,EAAC,mBAAmB,EAAE,mBAAmB,EAAC,MAAM,iBAAiB,CAAC;AAC9E,YAAY,EAAC,OAAO,IAAI,eAAe,EAAC,MAAM,0BAA0B,CAAC;AAGzE,OAAO,EAAC,OAAO,IAAI,gBAAgB,EAAC,MAAM,2BAA2B,CAAC;AAGtE,OAAO,EAAC,OAAO,IAAI,cAAc,EAAC,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAC,OAAO,IAAI,eAAe,EAAC,MAAM,yBAAyB,CAAC;AACnE,OAAO,EAAC,OAAO,IAAI,YAAY,EAAC,MAAM,sBAAsB,CAAC;AAC7D,OAAO,EAAC,OAAO,IAAI,iBAAiB,EAAC,MAAM,2BAA2B,CAAC;AACvE,OAAO,EAAC,OAAO,IAAI,uBAAuB,EAAC,MAAM,iCAAiC,CAAC;AAGnF,cAAc,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAaA;;GAEG;AAGH,OAAO,EAAC,OAAO,IAAI,mBAAmB,EAAC,MAAM,uBAAuB,CAAC;AAGrE,OAAO,EAAC,OAAO,IAAI,YAAY,EAAC,MAAM,0BAA0B,CAAC;AAGjE,YAAY,EAAC,mBAAmB,EAAE,mBAAmB,EAAC,MAAM,iBAAiB,CAAC;AAC9E,YAAY,EAAC,OAAO,IAAI,eAAe,EAAC,MAAM,0BAA0B,CAAC;AAGzE,OAAO,EAAC,OAAO,IAAI,gBAAgB,EAAC,MAAM,2BAA2B,CAAC;AAGtE,OAAO,EAAC,OAAO,IAAI,cAAc,EAAC,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAC,OAAO,IAAI,eAAe,EAAC,MAAM,yBAAyB,CAAC;AACnE,OAAO,EAAC,OAAO,IAAI,YAAY,EAAC,MAAM,sBAAsB,CAAC;AAC7D,OAAO,EAAC,OAAO,IAAI,YAAY,EAAC,MAAM,sBAAsB,CAAC;AAC7D,OAAO,EAAC,OAAO,IAAI,iBAAiB,EAAE,yBAAyB,EAAC,MAAM,2BAA2B,CAAC;AAClG,YAAY,EAAC,iBAAiB,EAAE,gBAAgB,EAAC,MAAM,2BAA2B,CAAC;AACnF,YAAY,EAAC,eAAe,EAAE,cAAc,EAAC,MAAM,sBAAsB,CAAC;AAC1E,OAAO,EAAC,OAAO,IAAI,iBAAiB,EAAC,MAAM,2BAA2B,CAAC;AACvE,OAAO,EAAC,OAAO,IAAI,uBAAuB,EAAC,MAAM,iCAAiC,CAAC;AAGnF,cAAc,uBAAuB,CAAC"}
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import fetch, { Headers, Request, Response } from "cross-fetch";
2
- import { ThunderIDAuthException, ThunderIDJavaScriptClient, getVendorPrefix } from "@thunderid/javascript";
2
+ import { BackchannelLogoutConstants, InvalidLogoutTokenError, ThunderIDAuthException, ThunderIDJavaScriptClient, getVendorPrefix, logger, resolveResourceEndpoint, updateMeCredentials } from "@thunderid/javascript";
3
3
  import cache from "memory-cache";
4
4
  import base64url from "base64url";
5
5
  import sha256 from "fast-sha256";
@@ -59,6 +59,82 @@ var NodeCryptoUtils = class {
59
59
  };
60
60
  var NodeCryptoUtils_default = NodeCryptoUtils;
61
61
 
62
+ //#endregion
63
+ //#region src/utils/SessionIndex.ts
64
+ const SID_INDEX = "backchannel_logout_sid";
65
+ const SUB_INDEX = "backchannel_logout_sub";
66
+ /**
67
+ * Finds local sessions by the server session identifier (`sid`) or the subject (`sub`) of their ID
68
+ * token. The storage adapter cannot list sessions, so a back-channel logout needs this to find the
69
+ * ones a logout token names. It lives in the same store as the sessions themselves.
70
+ */
71
+ var SessionIndex = class {
72
+ storageManager;
73
+ constructor(storageManager) {
74
+ this.storageManager = storageManager;
75
+ }
76
+ /**
77
+ * Records a session that just began.
78
+ */
79
+ async add(sessionId, binding, startedAt) {
80
+ if (binding.sid) {
81
+ const live = await this.dropEndedSessions(await this.read(SID_INDEX, binding.sid));
82
+ await this.write(SID_INDEX, binding.sid, {
83
+ ...live,
84
+ [sessionId]: startedAt
85
+ });
86
+ }
87
+ if (binding.sub) {
88
+ const live = await this.dropEndedSessions(await this.read(SUB_INDEX, binding.sub));
89
+ await this.write(SUB_INDEX, binding.sub, {
90
+ ...live,
91
+ [sessionId]: startedAt
92
+ });
93
+ }
94
+ }
95
+ findBySid(sid) {
96
+ return this.read(SID_INDEX, sid);
97
+ }
98
+ findBySub(sub) {
99
+ return this.read(SUB_INDEX, sub);
100
+ }
101
+ /**
102
+ * Forgets a session that has ended.
103
+ */
104
+ async remove(sessionId, binding) {
105
+ if (binding.sid) await this.removeEntry(SID_INDEX, binding.sid, sessionId);
106
+ if (binding.sub) await this.removeEntry(SUB_INDEX, binding.sub, sessionId);
107
+ }
108
+ async read(index, key) {
109
+ try {
110
+ return await this.storageManager.getCustomData(index, key) ?? {};
111
+ } catch {
112
+ return {};
113
+ }
114
+ }
115
+ async write(index, key, sessions) {
116
+ await this.storageManager.removeCustomData(index, key);
117
+ if (Object.keys(sessions).length > 0) await this.storageManager.setCustomData(index, sessions, key);
118
+ }
119
+ async removeEntry(index, key, sessionId) {
120
+ const sessions = await this.read(index, key);
121
+ if (!(sessionId in sessions)) return;
122
+ delete sessions[sessionId];
123
+ await this.write(index, key, sessions);
124
+ }
125
+ async dropEndedSessions(sessions) {
126
+ const checked = await Promise.all(Object.entries(sessions).map(async ([sessionId, startedAt]) => {
127
+ try {
128
+ return (await this.storageManager.getSessionData(sessionId))?.access_token ? [sessionId, startedAt] : void 0;
129
+ } catch {
130
+ return;
131
+ }
132
+ }));
133
+ return Object.fromEntries(checked.filter((entry) => entry !== void 0));
134
+ }
135
+ };
136
+ var SessionIndex_default = SessionIndex;
137
+
62
138
  //#endregion
63
139
  //#region src/utils/SessionUtils.ts
64
140
  const UUID_VERSION = 4;
@@ -101,6 +177,7 @@ var SessionUtils_default = SessionUtils;
101
177
  //#region src/ThunderIDNodeClient.ts
102
178
  var ThunderIDNodeClient = class extends ThunderIDJavaScriptClient {
103
179
  _nodeInstanceId = 0;
180
+ _handledLogoutTokens = /* @__PURE__ */ new Map();
104
181
  constructor(instanceId = 0) {
105
182
  super(void 0, new NodeCryptoUtils_default());
106
183
  this._nodeInstanceId = instanceId;
@@ -117,7 +194,7 @@ var ThunderIDNodeClient = class extends ThunderIDJavaScriptClient {
117
194
  return this._nodeInstanceId;
118
195
  }
119
196
  async signIn(...args) {
120
- const [authURLCallback, userId, authorizationCode, sessionState, state, signInConfig] = args;
197
+ const [authURLCallback, userId, authorizationCode, state, signInConfig] = args;
121
198
  if (!userId) return Promise.reject(new ThunderIDAuthException("NODE-AUTH_CLIENT-SI-NF01", "No user ID was provided.", "Unable to sign in the user as no user ID was provided."));
122
199
  if (await this.isSignedIn(userId)) {
123
200
  const sessionData$1 = await this.getStorageManager().getSessionData(userId);
@@ -144,8 +221,9 @@ var ThunderIDNodeClient = class extends ThunderIDJavaScriptClient {
144
221
  tokenType: ""
145
222
  });
146
223
  }
147
- await this.requestAccessToken(authorizationCode, sessionState ?? "", state, userId);
224
+ await this.requestAccessToken(authorizationCode, state, userId);
148
225
  const sessionData = await this.getStorageManager().getSessionData(userId);
226
+ await this.indexSession(userId, sessionData);
149
227
  return Promise.resolve({
150
228
  accessToken: sessionData.access_token,
151
229
  createdAt: sessionData.created_at,
@@ -167,6 +245,114 @@ var ThunderIDNodeClient = class extends ThunderIDJavaScriptClient {
167
245
  if (!signOutUrl) return Promise.reject(new ThunderIDAuthException("NODE-AUTH_CLIENT-SO-NF01", "Signing out the user failed.", "Could not obtain the sign-out URL from the server."));
168
246
  return signOutUrl;
169
247
  }
248
+ /**
249
+ * Handles an OpenID Connect back-channel logout: validates the logout token the server posted,
250
+ * then ends every local session it names. A token with a `sid` ends the sessions that joined
251
+ * that server session. A token with only a `sub` ends that subject's sessions that began before
252
+ * the token was issued, so a user who has already signed in again stays signed in.
253
+ *
254
+ * Ending a session here is local. The server has already revoked the tokens, so nothing is sent
255
+ * to it. A valid token that names no session held here succeeds with `sessionsEnded: 0`, which
256
+ * keeps a retried notification harmless.
257
+ *
258
+ * @param logoutToken - The raw `logout_token` form parameter.
259
+ * @returns The `sid` and `sub` the token named, when it was issued, and how many sessions were ended.
260
+ * @throws {InvalidLogoutTokenError} When the token is not valid or was already handled.
261
+ */
262
+ async handleBackchannelLogout(logoutToken) {
263
+ const claims = await this.validateLogoutToken(logoutToken);
264
+ this.assertNotReplayed(claims);
265
+ const index = new SessionIndex_default(this.getStorageManager());
266
+ let sessions;
267
+ if (claims.sid) sessions = await index.findBySid(claims.sid);
268
+ else {
269
+ const tolerance = (await this.getStorageManager().getConfigData())?.tokenValidation?.idToken?.clockTolerance ?? BackchannelLogoutConstants.DEFAULT_CLOCK_TOLERANCE_SECONDS;
270
+ const issuedBefore = (claims.iat + 1 + tolerance) * 1e3;
271
+ sessions = Object.fromEntries(Object.entries(await index.findBySub(claims.sub)).filter(([, startedAt]) => startedAt < issuedBefore));
272
+ }
273
+ let sessionsEnded = 0;
274
+ await Object.keys(sessions).reduce(async (previous, sessionId) => {
275
+ await previous;
276
+ const stored = await this.readSessionBinding(sessionId);
277
+ if (stored.sid && claims.sid && stored.sid !== claims.sid || stored.sub && claims.sub && stored.sub !== claims.sub) {
278
+ await index.remove(sessionId, {
279
+ sid: claims.sid,
280
+ sub: claims.sub
281
+ });
282
+ return;
283
+ }
284
+ await this.clearSessionAsync(sessionId);
285
+ await index.remove(sessionId, {
286
+ sid: claims.sid,
287
+ sub: claims.sub,
288
+ ...stored
289
+ });
290
+ sessionsEnded += 1;
291
+ }, Promise.resolve());
292
+ this.rememberHandled(claims);
293
+ return {
294
+ issuedAt: claims.iat,
295
+ sessionsEnded,
296
+ sid: claims.sid,
297
+ sub: claims.sub
298
+ };
299
+ }
300
+ /**
301
+ * Removes a session's index entries. Called wherever the stored session is removed, so the index
302
+ * does not keep growing: nothing else removes an entry once its session is gone. Sign-out does
303
+ * not remove the stored session, so it keeps the entry: the back-channel logout that follows
304
+ * finds the session through it.
305
+ */
306
+ async forgetSession(sessionId, sessionData) {
307
+ try {
308
+ const binding = await this.readSessionBinding(sessionId ?? "", sessionData);
309
+ if (binding.sid || binding.sub) await new SessionIndex_default(this.getStorageManager()).remove(sessionId ?? "", binding);
310
+ } catch {
311
+ logger.warn("Could not remove the session from the back-channel logout index.");
312
+ }
313
+ }
314
+ clearSession(sessionId) {
315
+ this.clearSessionAsync(sessionId);
316
+ }
317
+ async clearSessionAsync(sessionId) {
318
+ await this.forgetSession(sessionId);
319
+ await super.clearSessionAsync(sessionId);
320
+ }
321
+ /**
322
+ * Records a new session against the `sid` and `sub` of its ID token, so a back-channel logout
323
+ * can find it. Sign-in does not depend on it, so a failure here is logged and not raised.
324
+ */
325
+ async indexSession(sessionId, sessionData) {
326
+ try {
327
+ const binding = await this.readSessionBinding(sessionId, sessionData);
328
+ if (binding.sid || binding.sub) await new SessionIndex_default(this.getStorageManager()).add(sessionId, binding, Date.now());
329
+ } catch {
330
+ logger.warn("Could not index the session for back-channel logout.");
331
+ }
332
+ }
333
+ async readSessionBinding(sessionId, sessionData) {
334
+ const idToken = (sessionData ?? await this.getStorageManager().getSessionData(sessionId))?.id_token;
335
+ if (!idToken) return {};
336
+ const { sid, sub } = await this.decodeJwtToken(idToken);
337
+ return {
338
+ ...typeof sid === "string" && sid ? { sid } : {},
339
+ ...typeof sub === "string" && sub ? { sub } : {}
340
+ };
341
+ }
342
+ /**
343
+ * Refuses a logout token whose `jti` was already handled. Kept in this process only, so it is
344
+ * a best effort across instances; a replay still cannot end a session that began after it.
345
+ */
346
+ assertNotReplayed(claims) {
347
+ const now = Date.now();
348
+ this._handledLogoutTokens.forEach((expiresAt, jti) => {
349
+ if (expiresAt <= now) this._handledLogoutTokens.delete(jti);
350
+ });
351
+ if (this._handledLogoutTokens.has(claims.jti)) throw new InvalidLogoutTokenError("NODE-AUTH_CLIENT-HBL-IV01", "The logout token was already handled.");
352
+ }
353
+ rememberHandled(claims) {
354
+ this._handledLogoutTokens.set(claims.jti, claims.exp * 1e3);
355
+ }
170
356
  async isSignedIn(userId) {
171
357
  try {
172
358
  if (!await super.isSignedIn(userId)) return false;
@@ -174,6 +360,7 @@ var ThunderIDNodeClient = class extends ThunderIDJavaScriptClient {
174
360
  const sessionData = await sm.getSessionData(userId);
175
361
  if (await SessionUtils_default.validateSession(sessionData)) return true;
176
362
  if (await this.refreshAccessToken(userId)) return true;
363
+ await this.forgetSession(userId, sessionData);
177
364
  await sm.removeSessionData(userId);
178
365
  return false;
179
366
  } catch {
@@ -188,6 +375,7 @@ var ThunderIDNodeClient = class extends ThunderIDJavaScriptClient {
188
375
  return super.refreshAccessToken(userId);
189
376
  }
190
377
  async revokeAccessToken(userId) {
378
+ await this.forgetSession(userId);
191
379
  return super.revokeAccessToken(userId);
192
380
  }
193
381
  async getDecodedIdToken(userId, idToken) {
@@ -205,6 +393,20 @@ var ThunderIDNodeClient = class extends ThunderIDJavaScriptClient {
205
393
  async exchangeToken(config, userId) {
206
394
  return super.exchangeToken(config, userId);
207
395
  }
396
+ /**
397
+ * Updates one or more of the signed-in user's credentials (e.g. `password`, or any other
398
+ * attribute the user type schema declares `credential: true`).
399
+ */
400
+ async updateUserCredentials(payload, userId) {
401
+ const configData = await this.getStorageManager().getConfigData();
402
+ const baseUrl = configData?.baseUrl;
403
+ await updateMeCredentials({
404
+ baseUrl,
405
+ headers: { Authorization: `Bearer ${await this.getAccessToken(userId)}` },
406
+ payload,
407
+ url: resolveResourceEndpoint("usersMeCredentials", configData)
408
+ });
409
+ }
208
410
  };
209
411
  var ThunderIDNodeClient_default = ThunderIDNodeClient;
210
412
 
@@ -313,6 +515,100 @@ var CookieChunking = class CookieChunking {
313
515
  };
314
516
  var CookieChunking_default = CookieChunking;
315
517
 
518
+ //#endregion
519
+ //#region src/utils/LoggedOutSessions.ts
520
+ /**
521
+ * Keeps logout records in the memory of this process, dropping expired ones as new ones arrive.
522
+ */
523
+ var InMemoryLogoutRecordStore = class {
524
+ records = /* @__PURE__ */ new Map();
525
+ get(key) {
526
+ const record = this.records.get(key);
527
+ if (record && record.expiresAt <= Date.now()) {
528
+ this.records.delete(key);
529
+ return Promise.resolve(void 0);
530
+ }
531
+ return Promise.resolve(record?.value);
532
+ }
533
+ set(key, value, ttlSeconds) {
534
+ const now = Date.now();
535
+ this.records.forEach((record, recordKey) => {
536
+ if (record.expiresAt <= now) this.records.delete(recordKey);
537
+ });
538
+ this.records.set(key, {
539
+ expiresAt: now + ttlSeconds * 1e3,
540
+ value
541
+ });
542
+ return Promise.resolve();
543
+ }
544
+ };
545
+ /**
546
+ * The sessions that back-channel logout has ended, for an SDK whose session lives in a cookie the
547
+ * server cannot delete. The logout handler records each ended session here, and every later read
548
+ * of a session cookie asks whether it matches one.
549
+ *
550
+ * A record by `sid` ends exactly that server session. A record by `sub`, written only when the
551
+ * logout token carried no `sid`, ends that subject's sessions that began before the token was
552
+ * issued, so signing in again afterwards works.
553
+ */
554
+ var LoggedOutSessions = class LoggedOutSessions {
555
+ store;
556
+ constructor(store = new InMemoryLogoutRecordStore()) {
557
+ this.store = store;
558
+ }
559
+ /**
560
+ * Records a logout.
561
+ *
562
+ * @param logout - The `sid`, `sub` and `iat` of the validated logout token, and the clock
563
+ * tolerance its validation used. A logout by `sub` ends the sessions that began up to that
564
+ * long after `iat`: the clocks may differ by that much, and ending a session that began just
565
+ * after the logout is the safer mistake.
566
+ * @param ttlSeconds - The longest a matching session cookie could still be presented.
567
+ */
568
+ async record(logout, ttlSeconds) {
569
+ const expiresAt = Date.now() + ttlSeconds * 1e3;
570
+ if (logout.sid) {
571
+ await this.store.set(LoggedOutSessions.sidKey(logout.sid), JSON.stringify({ expiresAt }), ttlSeconds);
572
+ return;
573
+ }
574
+ if (logout.sub) {
575
+ const existing = await this.read(LoggedOutSessions.subKey(logout.sub));
576
+ const endedAt = Math.max(logout.issuedAt + (logout.clockTolerance ?? BackchannelLogoutConstants.DEFAULT_CLOCK_TOLERANCE_SECONDS), existing?.endedAt ?? 0);
577
+ await this.store.set(LoggedOutSessions.subKey(logout.sub), JSON.stringify({
578
+ endedAt,
579
+ expiresAt
580
+ }), ttlSeconds);
581
+ }
582
+ }
583
+ /**
584
+ * Reports whether the presented session was ended by a recorded logout.
585
+ */
586
+ async isLoggedOut(session) {
587
+ if (session.sid && await this.read(LoggedOutSessions.sidKey(session.sid))) return true;
588
+ if (!session.sub) return false;
589
+ const bySubject = await this.read(LoggedOutSessions.subKey(session.sub));
590
+ if (bySubject?.endedAt === void 0) return false;
591
+ return session.startedAt === void 0 || session.startedAt <= bySubject.endedAt;
592
+ }
593
+ async read(key) {
594
+ const value = await this.store.get(key);
595
+ if (!value) return;
596
+ try {
597
+ const record = JSON.parse(value);
598
+ return record.expiresAt > Date.now() ? record : void 0;
599
+ } catch {
600
+ return;
601
+ }
602
+ }
603
+ static sidKey(sid) {
604
+ return `thunderid:logout:sid:${encodeURIComponent(sid)}`;
605
+ }
606
+ static subKey(sub) {
607
+ return `thunderid:logout:sub:${encodeURIComponent(sub)}`;
608
+ }
609
+ };
610
+ var LoggedOutSessions_default = LoggedOutSessions;
611
+
316
612
  //#endregion
317
613
  //#region src/utils/generateSessionId.ts
318
614
  const generateSessionId = () => (/* @__PURE__ */ new Date()).getTime().toString(36) + Math.random().toString(36).substring(2);
@@ -337,4 +633,4 @@ if (!globalThis.fetch) {
337
633
  }
338
634
 
339
635
  //#endregion
340
- export { CookieChunking_default as CookieChunking, CookieConfig_default as CookieConfig, MemoryCacheStore_default as MemoryCacheStore, NodeCryptoUtils_default as NodeCryptoUtils, SessionUtils_default as SessionUtils, ThunderIDNodeClient_default as ThunderIDNodeClient, generateSessionId_default as generateSessionId, getSessionCookieOptions_default as getSessionCookieOptions };
636
+ export { CookieChunking_default as CookieChunking, CookieConfig_default as CookieConfig, InMemoryLogoutRecordStore, LoggedOutSessions_default as LoggedOutSessions, MemoryCacheStore_default as MemoryCacheStore, NodeCryptoUtils_default as NodeCryptoUtils, SessionIndex_default as SessionIndex, SessionUtils_default as SessionUtils, ThunderIDNodeClient_default as ThunderIDNodeClient, generateSessionId_default as generateSessionId, getSessionCookieOptions_default as getSessionCookieOptions };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Where an SDK that keeps its session in a cookie records the sessions a back-channel logout
3
+ * ended. Two operations, so a shared store such as Redis fits directly (`SET key value EX ttl`
4
+ * and `GET key`). The default keeps records in the memory of one process.
5
+ */
6
+ export interface LogoutRecordStore {
7
+ get(key: string): Promise<string | null | undefined>;
8
+ /**
9
+ * @param ttlSeconds - How long the record is needed. A store may drop it after that.
10
+ */
11
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
12
+ }
13
+ /**
14
+ * The session a request presents, as far as a logout record can match it.
15
+ */
16
+ export interface PresentedSession {
17
+ /** The server session the local session joined, from the `sid` of its ID token. */
18
+ sid?: string;
19
+ /** When the local session began, in seconds since the epoch. */
20
+ startedAt?: number;
21
+ sub?: string;
22
+ }
23
+ /**
24
+ * Keeps logout records in the memory of this process, dropping expired ones as new ones arrive.
25
+ */
26
+ export declare class InMemoryLogoutRecordStore implements LogoutRecordStore {
27
+ private records;
28
+ get(key: string): Promise<string | undefined>;
29
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
30
+ }
31
+ /**
32
+ * The sessions that back-channel logout has ended, for an SDK whose session lives in a cookie the
33
+ * server cannot delete. The logout handler records each ended session here, and every later read
34
+ * of a session cookie asks whether it matches one.
35
+ *
36
+ * A record by `sid` ends exactly that server session. A record by `sub`, written only when the
37
+ * logout token carried no `sid`, ends that subject's sessions that began before the token was
38
+ * issued, so signing in again afterwards works.
39
+ */
40
+ declare class LoggedOutSessions {
41
+ private store;
42
+ constructor(store?: LogoutRecordStore);
43
+ /**
44
+ * Records a logout.
45
+ *
46
+ * @param logout - The `sid`, `sub` and `iat` of the validated logout token, and the clock
47
+ * tolerance its validation used. A logout by `sub` ends the sessions that began up to that
48
+ * long after `iat`: the clocks may differ by that much, and ending a session that began just
49
+ * after the logout is the safer mistake.
50
+ * @param ttlSeconds - The longest a matching session cookie could still be presented.
51
+ */
52
+ record(logout: {
53
+ clockTolerance?: number;
54
+ issuedAt: number;
55
+ sid?: string;
56
+ sub?: string;
57
+ }, ttlSeconds: number): Promise<void>;
58
+ /**
59
+ * Reports whether the presented session was ended by a recorded logout.
60
+ */
61
+ isLoggedOut(session: PresentedSession): Promise<boolean>;
62
+ private read;
63
+ private static sidKey;
64
+ private static subKey;
65
+ }
66
+ export default LoggedOutSessions;
67
+ //# sourceMappingURL=LoggedOutSessions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LoggedOutSessions.d.ts","sourceRoot":"","sources":["../../src/utils/LoggedOutSessions.ts"],"names":[],"mappings":"AAKA;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IACrD;;OAEG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpE;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,mFAAmF;IACnF,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,gEAAgE;IAChE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AASD;;GAEG;AACH,qBAAa,yBAA0B,YAAW,iBAAiB;IACjE,OAAO,CAAC,OAAO,CAGX;IAEG,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAY7C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAY1E;AAED;;;;;;;;GAQG;AACH,cAAM,iBAAiB;IACrB,OAAO,CAAC,KAAK,CAAoB;gBAEd,KAAK,GAAE,iBAAmD;IAI7E;;;;;;;;OAQG;IACU,MAAM,CACjB,MAAM,EAAE;QAAC,cAAc,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAC,EAC/E,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,IAAI,CAAC;IAoBhB;;OAEG;IACU,WAAW,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC;YAkBvD,IAAI;IAiBlB,OAAO,CAAC,MAAM,CAAC,MAAM;IAIrB,OAAO,CAAC,MAAM,CAAC,MAAM;CAGtB;AAED,eAAe,iBAAiB,CAAC"}
@@ -0,0 +1,37 @@
1
+ import { StorageManager } from '@thunderid/javascript';
2
+ /**
3
+ * Local session identifiers mapped to the time each session began, in milliseconds.
4
+ */
5
+ export type IndexedSessions = Record<string, number>;
6
+ /**
7
+ * What ties a local session to the server: the server session it joined and its subject.
8
+ */
9
+ export interface SessionBinding {
10
+ sid?: string;
11
+ sub?: string;
12
+ }
13
+ /**
14
+ * Finds local sessions by the server session identifier (`sid`) or the subject (`sub`) of their ID
15
+ * token. The storage adapter cannot list sessions, so a back-channel logout needs this to find the
16
+ * ones a logout token names. It lives in the same store as the sessions themselves.
17
+ */
18
+ declare class SessionIndex<T = unknown> {
19
+ private storageManager;
20
+ constructor(storageManager: StorageManager<T>);
21
+ /**
22
+ * Records a session that just began.
23
+ */
24
+ add(sessionId: string, binding: SessionBinding, startedAt: number): Promise<void>;
25
+ findBySid(sid: string): Promise<IndexedSessions>;
26
+ findBySub(sub: string): Promise<IndexedSessions>;
27
+ /**
28
+ * Forgets a session that has ended.
29
+ */
30
+ remove(sessionId: string, binding: SessionBinding): Promise<void>;
31
+ private read;
32
+ private write;
33
+ private removeEntry;
34
+ private dropEndedSessions;
35
+ }
36
+ export default SessionIndex;
37
+ //# sourceMappingURL=SessionIndex.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SessionIndex.d.ts","sourceRoot":"","sources":["../../src/utils/SessionIndex.ts"],"names":[],"mappings":"AAGA,OAAO,EAAC,cAAc,EAAC,MAAM,uBAAuB,CAAC;AAKrD;;GAEG;AACH,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAErD;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;GAIG;AACH,cAAM,YAAY,CAAC,CAAC,GAAG,OAAO;IAC5B,OAAO,CAAC,cAAc,CAAoB;gBAEvB,cAAc,EAAE,cAAc,CAAC,CAAC,CAAC;IAIpD;;OAEG;IACU,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAcvF,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;IAIhD,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;IAIvD;;OAEG;IACU,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;YAShE,IAAI;YASJ,KAAK;YASL,WAAW;YAWX,iBAAiB;CAehC;AAED,eAAe,YAAY,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thunderid/node",
3
- "version": "1.0.7",
3
+ "version": "1.2.0",
4
4
  "description": "Node.js runtime SDK for ThunderID",
5
5
  "keywords": [
6
6
  "thunderid",
@@ -41,10 +41,10 @@
41
41
  "rimraf": "6.1.3",
42
42
  "rolldown": "1.0.0-beta.45",
43
43
  "typescript": "5.9.3",
44
- "vitest": "4.1.10"
44
+ "vitest": "4.1.11"
45
45
  },
46
46
  "dependencies": {
47
- "@thunderid/javascript": "^1.0.7",
47
+ "@thunderid/javascript": "^1.3.0",
48
48
  "base64url": "3.0.1",
49
49
  "cross-fetch": "4.1.0",
50
50
  "fast-sha256": "1.3.0",