@thunderid/node 1.1.0 → 1.2.1
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.
- package/dist/ThunderIDNodeClient.d.ts +38 -1
- package/dist/ThunderIDNodeClient.d.ts.map +1 -1
- package/dist/cjs/index.cjs +287 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +286 -4
- package/dist/utils/LoggedOutSessions.d.ts +67 -0
- package/dist/utils/LoggedOutSessions.d.ts.map +1 -0
- package/dist/utils/SessionIndex.d.ts +37 -0
- package/dist/utils/SessionIndex.d.ts.map +1 -0
- package/package.json +3 -3
|
@@ -1,13 +1,50 @@
|
|
|
1
|
-
import { ExtendedAuthorizeRequestUrlParams, IdToken, OIDCEndpoints, Storage, ThunderIDJavaScriptClient, 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
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>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ThunderIDNodeClient.d.ts","sourceRoot":"","sources":["../src/ThunderIDNodeClient.ts"],"names":[],"mappings":"AAGA,OAAO,
|
|
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"}
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -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,
|
|
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,
|
|
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) {
|
|
@@ -356,6 +544,100 @@ var CookieChunking = class CookieChunking {
|
|
|
356
544
|
};
|
|
357
545
|
var CookieChunking_default = CookieChunking;
|
|
358
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
|
+
|
|
359
641
|
//#endregion
|
|
360
642
|
//#region src/utils/generateSessionId.ts
|
|
361
643
|
const generateSessionId = () => (/* @__PURE__ */ new Date()).getTime().toString(36) + Math.random().toString(36).substring(2);
|
|
@@ -382,8 +664,11 @@ if (!globalThis.fetch) {
|
|
|
382
664
|
//#endregion
|
|
383
665
|
exports.CookieChunking = CookieChunking_default;
|
|
384
666
|
exports.CookieConfig = CookieConfig_default;
|
|
667
|
+
exports.InMemoryLogoutRecordStore = InMemoryLogoutRecordStore;
|
|
668
|
+
exports.LoggedOutSessions = LoggedOutSessions_default;
|
|
385
669
|
exports.MemoryCacheStore = MemoryCacheStore_default;
|
|
386
670
|
exports.NodeCryptoUtils = NodeCryptoUtils_default;
|
|
671
|
+
exports.SessionIndex = SessionIndex_default;
|
|
387
672
|
exports.SessionUtils = SessionUtils_default;
|
|
388
673
|
exports.ThunderIDNodeClient = ThunderIDNodeClient_default;
|
|
389
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';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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, resolveResourceEndpoint, updateMeCredentials } 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,
|
|
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,
|
|
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) {
|
|
@@ -327,6 +515,100 @@ var CookieChunking = class CookieChunking {
|
|
|
327
515
|
};
|
|
328
516
|
var CookieChunking_default = CookieChunking;
|
|
329
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
|
+
|
|
330
612
|
//#endregion
|
|
331
613
|
//#region src/utils/generateSessionId.ts
|
|
332
614
|
const generateSessionId = () => (/* @__PURE__ */ new Date()).getTime().toString(36) + Math.random().toString(36).substring(2);
|
|
@@ -351,4 +633,4 @@ if (!globalThis.fetch) {
|
|
|
351
633
|
}
|
|
352
634
|
|
|
353
635
|
//#endregion
|
|
354
|
-
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.1
|
|
3
|
+
"version": "1.2.1",
|
|
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.
|
|
44
|
+
"vitest": "4.1.11"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@thunderid/javascript": "^1.1
|
|
47
|
+
"@thunderid/javascript": "^1.3.1",
|
|
48
48
|
"base64url": "3.0.1",
|
|
49
49
|
"cross-fetch": "4.1.0",
|
|
50
50
|
"fast-sha256": "1.3.0",
|