@pod-os/elements 0.32.1-rc.0fc2066.0 → 0.32.1-rc.494c386.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.
- package/dist/cjs/ion-icon_31.cjs.entry.js +687 -358
- package/dist/cjs/ion-icon_31.cjs.entry.js.map +1 -1
- package/dist/collection/RefreshWorker.js +2044 -0
- package/dist/components/pos-app2.js +687 -358
- package/dist/components/pos-app2.js.map +1 -1
- package/dist/elements/RefreshWorker.js +2044 -0
- package/dist/elements/elements.esm.js +1 -1
- package/dist/elements/{p-ed5c0c18.entry.js → p-2a84b1f2.entry.js} +32 -32
- package/dist/elements/p-2a84b1f2.entry.js.map +1 -0
- package/dist/esm/ion-icon_31.entry.js +687 -358
- package/dist/esm/ion-icon_31.entry.js.map +1 -1
- package/package.json +2 -2
- package/dist/elements/p-ed5c0c18.entry.js.map +0 -1
|
@@ -2129,160 +2129,6 @@ const requestDynamicClientRegistration = async (registration_endpoint, client_de
|
|
|
2129
2129
|
});
|
|
2130
2130
|
};
|
|
2131
2131
|
|
|
2132
|
-
/**
|
|
2133
|
-
* A simple IndexedDB wrapper
|
|
2134
|
-
*/
|
|
2135
|
-
class SessionDatabase {
|
|
2136
|
-
dbName;
|
|
2137
|
-
storeName;
|
|
2138
|
-
dbVersion;
|
|
2139
|
-
db = null;
|
|
2140
|
-
/**
|
|
2141
|
-
* Creates a new instance
|
|
2142
|
-
* @param dbName The name of the IndexedDB database
|
|
2143
|
-
* @param storeName The name of the object store
|
|
2144
|
-
* @param dbVersion The database version
|
|
2145
|
-
*/
|
|
2146
|
-
constructor(dbName = 'soidc', storeName = 'session', dbVersion = 1) {
|
|
2147
|
-
this.dbName = dbName;
|
|
2148
|
-
this.storeName = storeName;
|
|
2149
|
-
this.dbVersion = dbVersion;
|
|
2150
|
-
}
|
|
2151
|
-
/**
|
|
2152
|
-
* Initializes the IndexedDB database
|
|
2153
|
-
* @returns Promise that resolves when the database is ready
|
|
2154
|
-
*/
|
|
2155
|
-
async init() {
|
|
2156
|
-
return new Promise((resolve, reject) => {
|
|
2157
|
-
const request = indexedDB.open(this.dbName, this.dbVersion);
|
|
2158
|
-
request.onerror = (event) => {
|
|
2159
|
-
reject(new Error(`Database error: ${event.target.error}`));
|
|
2160
|
-
};
|
|
2161
|
-
request.onsuccess = (event) => {
|
|
2162
|
-
this.db = event.target.result;
|
|
2163
|
-
resolve(this);
|
|
2164
|
-
};
|
|
2165
|
-
request.onupgradeneeded = (event) => {
|
|
2166
|
-
const db = event.target.result;
|
|
2167
|
-
// Check if the object store already exists, if not create it
|
|
2168
|
-
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
2169
|
-
db.createObjectStore(this.storeName);
|
|
2170
|
-
}
|
|
2171
|
-
};
|
|
2172
|
-
});
|
|
2173
|
-
}
|
|
2174
|
-
/**
|
|
2175
|
-
* Stores any value in the database with the given ID as key
|
|
2176
|
-
* @param id The identifier/key for the value
|
|
2177
|
-
* @param value The value to store
|
|
2178
|
-
*/
|
|
2179
|
-
async setItem(id, value) {
|
|
2180
|
-
if (!this.db) {
|
|
2181
|
-
await this.init();
|
|
2182
|
-
}
|
|
2183
|
-
return new Promise((resolve, reject) => {
|
|
2184
|
-
const transaction = this.db.transaction(this.storeName, 'readwrite');
|
|
2185
|
-
// Handle transation
|
|
2186
|
-
transaction.oncomplete = () => {
|
|
2187
|
-
resolve();
|
|
2188
|
-
};
|
|
2189
|
-
transaction.onerror = (event) => {
|
|
2190
|
-
reject(new Error(`Transaction error for setItem(${id},...): ${event.target.error}`));
|
|
2191
|
-
};
|
|
2192
|
-
transaction.onabort = (event) => {
|
|
2193
|
-
reject(new Error(`Transaction aborted for setItem(${id},...): ${event.target.error}`));
|
|
2194
|
-
};
|
|
2195
|
-
// Perform the request within the transaction
|
|
2196
|
-
const store = transaction.objectStore(this.storeName);
|
|
2197
|
-
store.put(value, id);
|
|
2198
|
-
});
|
|
2199
|
-
}
|
|
2200
|
-
/**
|
|
2201
|
-
* Retrieves a value from the database by ID
|
|
2202
|
-
* @param id The identifier/key for the value
|
|
2203
|
-
* @returns The stored value or null if not found
|
|
2204
|
-
*/
|
|
2205
|
-
async getItem(id) {
|
|
2206
|
-
if (!this.db) {
|
|
2207
|
-
await this.init();
|
|
2208
|
-
}
|
|
2209
|
-
return new Promise((resolve, reject) => {
|
|
2210
|
-
const transaction = this.db.transaction(this.storeName, 'readonly');
|
|
2211
|
-
// Handle transation
|
|
2212
|
-
transaction.onerror = (event) => {
|
|
2213
|
-
reject(new Error(`Transaction error for getItem(${id}): ${event.target.error}`));
|
|
2214
|
-
};
|
|
2215
|
-
transaction.onabort = (event) => {
|
|
2216
|
-
reject(new Error(`Transaction aborted for getItem(${id}): ${event.target.error}`));
|
|
2217
|
-
};
|
|
2218
|
-
// Perform the request within the transaction
|
|
2219
|
-
const store = transaction.objectStore(this.storeName);
|
|
2220
|
-
const request = store.get(id);
|
|
2221
|
-
request.onsuccess = () => {
|
|
2222
|
-
resolve(request.result || null);
|
|
2223
|
-
};
|
|
2224
|
-
});
|
|
2225
|
-
}
|
|
2226
|
-
/**
|
|
2227
|
-
* Removes an item from the database
|
|
2228
|
-
* @param id The identifier of the item to remove
|
|
2229
|
-
*/
|
|
2230
|
-
async deleteItem(id) {
|
|
2231
|
-
if (!this.db) {
|
|
2232
|
-
await this.init();
|
|
2233
|
-
}
|
|
2234
|
-
return new Promise((resolve, reject) => {
|
|
2235
|
-
const transaction = this.db.transaction(this.storeName, 'readwrite');
|
|
2236
|
-
// Handle transation
|
|
2237
|
-
transaction.oncomplete = () => {
|
|
2238
|
-
resolve();
|
|
2239
|
-
};
|
|
2240
|
-
transaction.onerror = (event) => {
|
|
2241
|
-
reject(new Error(`Transaction error for deleteItem(${id}): ${event.target.error}`));
|
|
2242
|
-
};
|
|
2243
|
-
transaction.onabort = (event) => {
|
|
2244
|
-
reject(new Error(`Transaction aborted for deleteItem(${id}): ${event.target.error}`));
|
|
2245
|
-
};
|
|
2246
|
-
// Perform the request within the transaction
|
|
2247
|
-
const store = transaction.objectStore(this.storeName);
|
|
2248
|
-
store.delete(id);
|
|
2249
|
-
});
|
|
2250
|
-
}
|
|
2251
|
-
/**
|
|
2252
|
-
* Clears all items from the database
|
|
2253
|
-
*/
|
|
2254
|
-
async clear() {
|
|
2255
|
-
if (!this.db) {
|
|
2256
|
-
await this.init();
|
|
2257
|
-
}
|
|
2258
|
-
return new Promise((resolve, reject) => {
|
|
2259
|
-
const transaction = this.db.transaction(this.storeName, 'readwrite');
|
|
2260
|
-
// Handle transation
|
|
2261
|
-
transaction.oncomplete = () => {
|
|
2262
|
-
resolve();
|
|
2263
|
-
};
|
|
2264
|
-
transaction.onerror = (event) => {
|
|
2265
|
-
reject(new Error(`Transaction error for clear(): ${event.target.error}`));
|
|
2266
|
-
};
|
|
2267
|
-
transaction.onabort = (event) => {
|
|
2268
|
-
reject(new Error(`Transaction aborted for clear(): ${event.target.error}`));
|
|
2269
|
-
};
|
|
2270
|
-
// Perform the request within the transaction
|
|
2271
|
-
const store = transaction.objectStore(this.storeName);
|
|
2272
|
-
store.clear();
|
|
2273
|
-
});
|
|
2274
|
-
}
|
|
2275
|
-
/**
|
|
2276
|
-
* Closes the database connection
|
|
2277
|
-
*/
|
|
2278
|
-
close() {
|
|
2279
|
-
if (this.db) {
|
|
2280
|
-
this.db.close();
|
|
2281
|
-
this.db = null;
|
|
2282
|
-
}
|
|
2283
|
-
}
|
|
2284
|
-
}
|
|
2285
|
-
|
|
2286
2132
|
/**
|
|
2287
2133
|
* Login with the idp, using a provided `client_id` or dynamic client registration if none provided.
|
|
2288
2134
|
*
|
|
@@ -2306,7 +2152,7 @@ const redirectForLogin = async (idp, redirect_uri, client_details) => {
|
|
|
2306
2152
|
const issuer = openid_configuration["issuer"];
|
|
2307
2153
|
const trim_trailing_slash = (url) => (url.endsWith('/') ? url.slice(0, -1) : url);
|
|
2308
2154
|
if (trim_trailing_slash(idp) !== trim_trailing_slash(issuer)) { // expected idp matches received issuer mod trailing slash?
|
|
2309
|
-
throw new Error("RFC 9207 - iss
|
|
2155
|
+
throw new Error("RFC 9207 - iss !== idp - " + issuer + " !== " + idp);
|
|
2310
2156
|
}
|
|
2311
2157
|
sessionStorage.setItem("idp", issuer);
|
|
2312
2158
|
// remember token endpoint
|
|
@@ -2327,7 +2173,12 @@ const redirectForLogin = async (idp, redirect_uri, client_details) => {
|
|
|
2327
2173
|
return response.json();
|
|
2328
2174
|
});
|
|
2329
2175
|
client_id = client_registration["client_id"];
|
|
2330
|
-
|
|
2176
|
+
}
|
|
2177
|
+
// remember client_id if not URL
|
|
2178
|
+
try {
|
|
2179
|
+
new URL(client_id);
|
|
2180
|
+
}
|
|
2181
|
+
catch {
|
|
2331
2182
|
sessionStorage.setItem("client_id", client_id);
|
|
2332
2183
|
}
|
|
2333
2184
|
// RFC 7636 PKCE, remember code verifer
|
|
@@ -2368,7 +2219,7 @@ const getPKCEcode = async () => {
|
|
|
2368
2219
|
* URL contains authrization code, issuer (idp) and state (csrf token),
|
|
2369
2220
|
* get an access token for the authrization code.
|
|
2370
2221
|
*/
|
|
2371
|
-
const onIncomingRedirect = async (client_details) => {
|
|
2222
|
+
const onIncomingRedirect = async (client_details, database) => {
|
|
2372
2223
|
const url = new URL(window.location.href);
|
|
2373
2224
|
// authorization code
|
|
2374
2225
|
const authorization_code = url.searchParams.get("code");
|
|
@@ -2378,12 +2229,12 @@ const onIncomingRedirect = async (client_details) => {
|
|
|
2378
2229
|
}
|
|
2379
2230
|
// RFC 9207 issuer check
|
|
2380
2231
|
const idp = sessionStorage.getItem("idp");
|
|
2381
|
-
if (idp === null || url.searchParams.get("iss")
|
|
2382
|
-
throw new Error("RFC 9207 - iss
|
|
2232
|
+
if (idp === null || url.searchParams.get("iss") !== idp) {
|
|
2233
|
+
throw new Error("RFC 9207 - iss !== idp - " + url.searchParams.get("iss") + " !== " + idp);
|
|
2383
2234
|
}
|
|
2384
2235
|
// RFC 6749 OAuth 2.0
|
|
2385
|
-
if (url.searchParams.get("state")
|
|
2386
|
-
throw new Error("RFC 6749 - state
|
|
2236
|
+
if (url.searchParams.get("state") !== sessionStorage.getItem("csrf_token")) {
|
|
2237
|
+
throw new Error("RFC 6749 - state !== csrf_token - " + url.searchParams.get("state") + " !== " + sessionStorage.getItem("csrf_token"));
|
|
2387
2238
|
}
|
|
2388
2239
|
// remove redirect query parameters from URL
|
|
2389
2240
|
url.searchParams.delete("iss");
|
|
@@ -2427,30 +2278,32 @@ const onIncomingRedirect = async (client_details) => {
|
|
|
2427
2278
|
});
|
|
2428
2279
|
// check dpop thumbprint
|
|
2429
2280
|
const dpopThumbprint = await calculateJwkThumbprint(await exportJWK(key_pair.publicKey));
|
|
2430
|
-
if (payload["cnf"]["jkt"]
|
|
2431
|
-
throw new Error("Access Token validation failed on `jkt`: jkt
|
|
2281
|
+
if (payload["cnf"]["jkt"] !== dpopThumbprint) {
|
|
2282
|
+
throw new Error("Access Token validation failed on `jkt`: jkt !== DPoP thumbprint - " + payload["cnf"]["jkt"] + " !== " + dpopThumbprint);
|
|
2432
2283
|
}
|
|
2433
2284
|
// check client_id
|
|
2434
|
-
if (payload["client_id"]
|
|
2435
|
-
throw new Error("Access Token validation failed on `client_id`: JWT payload
|
|
2285
|
+
if (payload["client_id"] !== client_id) {
|
|
2286
|
+
throw new Error("Access Token validation failed on `client_id`: JWT payload !== client_id - " + payload["client_id"] + " !== " + client_id);
|
|
2436
2287
|
}
|
|
2437
2288
|
// summarise session info
|
|
2438
2289
|
const token_details = { ...token_response, dpop_key_pair: key_pair };
|
|
2439
2290
|
const idp_details = { idp, jwks_uri, token_endpoint };
|
|
2440
2291
|
if (!client_details)
|
|
2441
|
-
client_details = { redirect_uris: [
|
|
2292
|
+
client_details = { redirect_uris: [url.toString()] };
|
|
2442
2293
|
client_details.client_id = client_id;
|
|
2443
|
-
//
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2294
|
+
// and persist refresh token details
|
|
2295
|
+
if (database) {
|
|
2296
|
+
await database.init();
|
|
2297
|
+
await Promise.all([
|
|
2298
|
+
database.setItem("idp", idp),
|
|
2299
|
+
database.setItem("jwks_uri", jwks_uri),
|
|
2300
|
+
database.setItem("token_endpoint", token_endpoint),
|
|
2301
|
+
database.setItem("client_id", client_id),
|
|
2302
|
+
database.setItem("dpop_keypair", key_pair),
|
|
2303
|
+
database.setItem("refresh_token", token_response["refresh_token"])
|
|
2304
|
+
]);
|
|
2305
|
+
database.close();
|
|
2306
|
+
}
|
|
2454
2307
|
// clean session storage
|
|
2455
2308
|
sessionStorage.removeItem("csrf_token");
|
|
2456
2309
|
sessionStorage.removeItem("pkce_code_verifier");
|
|
@@ -2508,56 +2361,60 @@ const requestAccessToken = async (authorization_code, pkce_code_verifier, redire
|
|
|
2508
2361
|
});
|
|
2509
2362
|
};
|
|
2510
2363
|
|
|
2511
|
-
const renewTokens = async () => {
|
|
2364
|
+
const renewTokens = async (sessionDatabase) => {
|
|
2512
2365
|
// remember session details
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2366
|
+
try {
|
|
2367
|
+
await sessionDatabase.init();
|
|
2368
|
+
const client_id = await sessionDatabase.getItem("client_id");
|
|
2369
|
+
const token_endpoint = await sessionDatabase.getItem("token_endpoint");
|
|
2370
|
+
const key_pair = await sessionDatabase.getItem("dpop_keypair");
|
|
2371
|
+
const refresh_token = await sessionDatabase.getItem("refresh_token");
|
|
2372
|
+
if (client_id === null || token_endpoint === null || key_pair === null || refresh_token === null) {
|
|
2373
|
+
// we can not restore the old session
|
|
2374
|
+
throw new Error("Could not refresh tokens: details missing from database.");
|
|
2375
|
+
}
|
|
2376
|
+
const token_response = await requestFreshTokens(refresh_token, client_id, token_endpoint, key_pair)
|
|
2377
|
+
.then((response) => {
|
|
2378
|
+
if (!response.ok) {
|
|
2379
|
+
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
2380
|
+
}
|
|
2381
|
+
return response.json();
|
|
2382
|
+
});
|
|
2383
|
+
// verify access_token // ! Solid-OIDC specification says it should be a dpop-bound `id token` but implementations provide a dpop-bound `access token`
|
|
2384
|
+
const accessToken = token_response["access_token"];
|
|
2385
|
+
const idp = await sessionDatabase.getItem("idp");
|
|
2386
|
+
if (idp === null) {
|
|
2387
|
+
throw new Error("Access Token validation preparation - Could not find in sessionDatabase: idp");
|
|
2388
|
+
}
|
|
2389
|
+
const jwks_uri = await sessionDatabase.getItem("jwks_uri");
|
|
2390
|
+
if (jwks_uri === null) {
|
|
2391
|
+
throw new Error("Access Token validation preparation - Could not find in sessionDatabase: jwks_uri");
|
|
2392
|
+
}
|
|
2393
|
+
const jwks = createRemoteJWKSet(new URL(jwks_uri));
|
|
2394
|
+
const { payload } = await jwtVerify(accessToken, jwks, {
|
|
2395
|
+
issuer: idp, // RFC 9207
|
|
2396
|
+
audience: "solid", // RFC 7519 // ! "solid" as per implementations ...
|
|
2397
|
+
// exp, nbf, iat - handled automatically
|
|
2398
|
+
});
|
|
2399
|
+
// check dpop thumbprint
|
|
2400
|
+
const dpopThumbprint = await calculateJwkThumbprint(await exportJWK(key_pair.publicKey));
|
|
2401
|
+
if (payload["cnf"]["jkt"] !== dpopThumbprint) {
|
|
2402
|
+
throw new Error("Access Token validation failed on `jkt`: jkt !== DPoP thumbprint - " + payload["cnf"]["jkt"] + " !== " + dpopThumbprint);
|
|
2526
2403
|
}
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
throw new Error("Access Token validation preparation - Could not find in sessionDatabase: jwks_uri");
|
|
2538
|
-
}
|
|
2539
|
-
const jwks = createRemoteJWKSet(new URL(jwks_uri));
|
|
2540
|
-
const { payload } = await jwtVerify(accessToken, jwks, {
|
|
2541
|
-
issuer: idp, // RFC 9207
|
|
2542
|
-
audience: "solid", // RFC 7519 // ! "solid" as per implementations ...
|
|
2543
|
-
// exp, nbf, iat - handled automatically
|
|
2544
|
-
});
|
|
2545
|
-
// check dpop thumbprint
|
|
2546
|
-
const dpopThumbprint = await calculateJwkThumbprint(await exportJWK(key_pair.publicKey));
|
|
2547
|
-
if (payload["cnf"]["jkt"] != dpopThumbprint) {
|
|
2548
|
-
throw new Error("Access Token validation failed on `jkt`: jkt != DPoP thumbprint - " + payload["cnf"]["jkt"] + " != " + dpopThumbprint);
|
|
2404
|
+
// check client_id
|
|
2405
|
+
if (payload["client_id"] !== client_id) {
|
|
2406
|
+
throw new Error("Access Token validation failed on `client_id`: JWT payload !== client_id - " + payload["client_id"] + " !== " + client_id);
|
|
2407
|
+
}
|
|
2408
|
+
// set new refresh token for token rotation
|
|
2409
|
+
await sessionDatabase.setItem("refresh_token", token_response["refresh_token"]);
|
|
2410
|
+
return {
|
|
2411
|
+
...token_response,
|
|
2412
|
+
dpop_key_pair: key_pair,
|
|
2413
|
+
};
|
|
2549
2414
|
}
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
throw new Error("Access Token validation failed on `client_id`: JWT payload != client_id - " + payload["client_id"] + " != " + client_id);
|
|
2415
|
+
finally {
|
|
2416
|
+
sessionDatabase.close();
|
|
2553
2417
|
}
|
|
2554
|
-
// set new refresh token for token rotation
|
|
2555
|
-
await sessionDatabase.setItem("refresh_token", token_response["refresh_token"]);
|
|
2556
|
-
sessionDatabase.close();
|
|
2557
|
-
return {
|
|
2558
|
-
...token_response,
|
|
2559
|
-
dpop_key_pair: key_pair,
|
|
2560
|
-
};
|
|
2561
2418
|
};
|
|
2562
2419
|
/**
|
|
2563
2420
|
* Request an dpop-bound access token from a token endpoint using a refresh token
|
|
@@ -2579,7 +2436,7 @@ const requestFreshTokens = async (refresh_token, client_id, token_endpoint, key_
|
|
|
2579
2436
|
htm: "POST",
|
|
2580
2437
|
})
|
|
2581
2438
|
.setIssuedAt()
|
|
2582
|
-
.setJti(
|
|
2439
|
+
.setJti(self.crypto.randomUUID())
|
|
2583
2440
|
.setProtectedHeader({
|
|
2584
2441
|
alg: "ES256",
|
|
2585
2442
|
typ: "dpop+jwt",
|
|
@@ -2600,113 +2457,118 @@ const requestFreshTokens = async (refresh_token, client_id, token_endpoint, key_
|
|
|
2600
2457
|
});
|
|
2601
2458
|
};
|
|
2602
2459
|
|
|
2603
|
-
|
|
2604
|
-
|
|
2460
|
+
//
|
|
2461
|
+
//
|
|
2462
|
+
// Basic implementation
|
|
2463
|
+
//
|
|
2464
|
+
//
|
|
2465
|
+
/**
|
|
2466
|
+
* The SessionCore class manages session state and core logic but does not handle the refresh lifecycle.
|
|
2467
|
+
* It receives {@link SessionOptions} with a database to be able to restore a session.
|
|
2468
|
+
* That database can be re-used by (your!) surrounding implementation to handle the refresh lifecycle.
|
|
2469
|
+
* If no database was provided, refresh information cannot be stored, and thus token refresh (via the refresh token grant) is not possible in this case.
|
|
2470
|
+
*
|
|
2471
|
+
* If you are building a web app, use the Session implementation provided in the default `/web` version of this library.
|
|
2472
|
+
*/
|
|
2473
|
+
class SessionCore {
|
|
2605
2474
|
isActive_ = false;
|
|
2475
|
+
exp_;
|
|
2606
2476
|
webId_ = undefined;
|
|
2607
2477
|
currentAth_ = undefined;
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2478
|
+
onSessionStateChange;
|
|
2479
|
+
information;
|
|
2480
|
+
database;
|
|
2481
|
+
refreshPromise;
|
|
2482
|
+
resolveRefresh;
|
|
2483
|
+
rejectRefresh;
|
|
2614
2484
|
constructor(clientDetails, sessionOptions) {
|
|
2615
|
-
this.
|
|
2616
|
-
this.
|
|
2485
|
+
this.information = { clientDetails };
|
|
2486
|
+
this.database = sessionOptions?.database;
|
|
2487
|
+
this.onSessionStateChange = sessionOptions?.onSessionStateChange;
|
|
2617
2488
|
}
|
|
2618
|
-
/**
|
|
2619
|
-
* Redirect the user for login to their IDP.
|
|
2620
|
-
*
|
|
2621
|
-
* @throws Error if the session has not been initialized.
|
|
2622
|
-
*/
|
|
2623
2489
|
async login(idp, redirect_uri) {
|
|
2624
|
-
await redirectForLogin(idp, redirect_uri, this.
|
|
2625
|
-
}
|
|
2626
|
-
/**
|
|
2627
|
-
* Clears all session-related information, including IDP details and tokens.
|
|
2628
|
-
* This logs the user out.
|
|
2629
|
-
* Client details are preserved.
|
|
2630
|
-
*/
|
|
2631
|
-
async logout() {
|
|
2632
|
-
// clear timeouts
|
|
2633
|
-
if (this.sessionDeactivateTimeout)
|
|
2634
|
-
clearTimeout(this.sessionDeactivateTimeout);
|
|
2635
|
-
this.sessionDeactivateTimeout = undefined;
|
|
2636
|
-
if (this.tokenRefreshTimeout)
|
|
2637
|
-
clearTimeout(this.tokenRefreshTimeout);
|
|
2638
|
-
this.tokenRefreshTimeout = undefined;
|
|
2639
|
-
// clean session data
|
|
2640
|
-
this.sessionInformation.idpDetails = undefined;
|
|
2641
|
-
this.sessionInformation.tokenDetails = undefined;
|
|
2642
|
-
this.isActive_ = false;
|
|
2643
|
-
this.webId_ = undefined;
|
|
2644
|
-
// only preserve client_id if URI
|
|
2645
|
-
if (this.sessionInformation.clientDetails?.client_id)
|
|
2646
|
-
try {
|
|
2647
|
-
new URL(this.sessionInformation.clientDetails.client_id);
|
|
2648
|
-
}
|
|
2649
|
-
catch (_) {
|
|
2650
|
-
this.sessionInformation.clientDetails.client_id = undefined;
|
|
2651
|
-
}
|
|
2652
|
-
// clean session database
|
|
2653
|
-
const sessionDatabase = await new SessionDatabase().init();
|
|
2654
|
-
await sessionDatabase.clear();
|
|
2655
|
-
sessionDatabase.close();
|
|
2490
|
+
await redirectForLogin(idp, redirect_uri, this.information.clientDetails);
|
|
2656
2491
|
}
|
|
2657
2492
|
/**
|
|
2658
2493
|
* Handles the redirect from the identity provider after a login attempt.
|
|
2659
2494
|
* It attempts to retrieve tokens using the authorization code.
|
|
2495
|
+
* Upon success, it tries to persist information to refresh tokens in the session database.
|
|
2496
|
+
* If no database was provided, no information is persisted.
|
|
2660
2497
|
*/
|
|
2661
2498
|
async handleRedirectFromLogin() {
|
|
2662
2499
|
// Redirect after Authorization Code Grant // memory via sessionStorage
|
|
2663
|
-
const newSessionInfo = await onIncomingRedirect(this.
|
|
2500
|
+
const newSessionInfo = await onIncomingRedirect(this.information.clientDetails, this.database);
|
|
2664
2501
|
// no session - we remain unauthenticated
|
|
2665
|
-
if (!newSessionInfo.tokenDetails)
|
|
2502
|
+
if (!newSessionInfo.tokenDetails)
|
|
2666
2503
|
return;
|
|
2667
|
-
}
|
|
2668
2504
|
// we got a session
|
|
2669
|
-
this.
|
|
2670
|
-
|
|
2505
|
+
this.information.clientDetails = newSessionInfo.clientDetails;
|
|
2506
|
+
this.information.idpDetails = newSessionInfo.idpDetails;
|
|
2507
|
+
await this.setTokenDetails(newSessionInfo.tokenDetails);
|
|
2508
|
+
// callback state change
|
|
2509
|
+
this.onSessionStateChange?.(); // we logged in
|
|
2671
2510
|
}
|
|
2672
2511
|
/**
|
|
2673
2512
|
* Handles session restoration using the refresh token grant.
|
|
2674
2513
|
* Silently fails if session could not be restored (maybe there was no session in the first place).
|
|
2675
2514
|
*/
|
|
2676
2515
|
async restore() {
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
this.
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2516
|
+
if (!this.database) {
|
|
2517
|
+
throw new Error("Could not refresh tokens: missing database. Provide database in sessionOption.");
|
|
2518
|
+
}
|
|
2519
|
+
if (this.refreshPromise) {
|
|
2520
|
+
return this.refreshPromise;
|
|
2521
|
+
}
|
|
2522
|
+
this.refreshPromise = new Promise((resolve, reject) => {
|
|
2523
|
+
this.resolveRefresh = resolve;
|
|
2524
|
+
this.rejectRefresh = reject;
|
|
2525
|
+
});
|
|
2526
|
+
// Restore session using Refresh Token Grant
|
|
2527
|
+
const wasActive = this.isActive;
|
|
2528
|
+
renewTokens(this.database)
|
|
2529
|
+
.then(tokenDetails => this.setTokenDetails(tokenDetails))
|
|
2530
|
+
.then(() => this.resolveRefresh())
|
|
2531
|
+
.catch(error => {
|
|
2532
|
+
if (this.isActive) {
|
|
2533
|
+
this.rejectRefresh(new Error(error || 'Token refresh failed'));
|
|
2534
|
+
// do not change state (yet), let the app decide if they want to logout or if they just want to retry.
|
|
2535
|
+
}
|
|
2536
|
+
else {
|
|
2537
|
+
this.rejectRefresh(new Error("No session to restore."));
|
|
2538
|
+
}
|
|
2539
|
+
}).finally(() => {
|
|
2540
|
+
this.clearRefreshPromise();
|
|
2541
|
+
if (wasActive !== this.isActive)
|
|
2542
|
+
this.onSessionStateChange?.();
|
|
2543
|
+
});
|
|
2544
|
+
return this.refreshPromise;
|
|
2687
2545
|
}
|
|
2688
2546
|
/**
|
|
2689
|
-
*
|
|
2690
|
-
*
|
|
2691
|
-
*
|
|
2692
|
-
* @returns A promise that resolves to the signed DPoP token string.
|
|
2693
|
-
* @throws Error if the session has not been initialized - if no token details are available.
|
|
2547
|
+
* This logs the user out.
|
|
2548
|
+
* Clears all session-related information, including IDP details and tokens.
|
|
2549
|
+
* Client ID is preserved if it is a URI.
|
|
2694
2550
|
*/
|
|
2695
|
-
async
|
|
2696
|
-
|
|
2697
|
-
|
|
2551
|
+
async logout() {
|
|
2552
|
+
// clean session data
|
|
2553
|
+
this.isActive_ = false;
|
|
2554
|
+
this.exp_ = undefined;
|
|
2555
|
+
this.webId_ = undefined;
|
|
2556
|
+
this.currentAth_ = undefined;
|
|
2557
|
+
this.information.idpDetails = undefined;
|
|
2558
|
+
this.information.tokenDetails = undefined;
|
|
2559
|
+
// client details are preserved
|
|
2560
|
+
if (this.refreshPromise && this.rejectRefresh) {
|
|
2561
|
+
this.rejectRefresh(new Error('Logout during token refresh.'));
|
|
2562
|
+
this.clearRefreshPromise();
|
|
2698
2563
|
}
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
.
|
|
2703
|
-
.
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
jwk: jwk_public_key,
|
|
2708
|
-
})
|
|
2709
|
-
.sign(this.sessionInformation.tokenDetails.dpop_key_pair.privateKey);
|
|
2564
|
+
// clean session database
|
|
2565
|
+
if (this.database) {
|
|
2566
|
+
await this.database.init();
|
|
2567
|
+
await this.database.clear();
|
|
2568
|
+
this.database.close();
|
|
2569
|
+
}
|
|
2570
|
+
// callback state change
|
|
2571
|
+
this.onSessionStateChange?.(); // we logged out
|
|
2710
2572
|
}
|
|
2711
2573
|
/**
|
|
2712
2574
|
* Makes an HTTP fetch request.
|
|
@@ -2718,10 +2580,18 @@ class Session {
|
|
|
2718
2580
|
* @returns A promise that resolves to the fetch Response.
|
|
2719
2581
|
*/
|
|
2720
2582
|
async authFetch(input, init, dpopPayload) {
|
|
2583
|
+
// if there is not session established, just delegate to the default fetch
|
|
2584
|
+
if (!this.isActive) {
|
|
2585
|
+
return fetch(input, init);
|
|
2586
|
+
}
|
|
2587
|
+
// TODO
|
|
2588
|
+
// TODO do HEAD request to check if authentication is actually required, only then include tokens
|
|
2589
|
+
// TODO
|
|
2721
2590
|
// prepare authenticated call using a DPoP token (either provided payload, or default)
|
|
2722
2591
|
let url;
|
|
2723
2592
|
let method;
|
|
2724
2593
|
let headers;
|
|
2594
|
+
// wrangle fetch input parameters into place
|
|
2725
2595
|
if (input instanceof Request) {
|
|
2726
2596
|
url = new URL(input.url);
|
|
2727
2597
|
method = init?.method || input?.method || 'GET';
|
|
@@ -2734,15 +2604,15 @@ class Session {
|
|
|
2734
2604
|
headers = init.headers ? new Headers(init.headers) : new Headers();
|
|
2735
2605
|
}
|
|
2736
2606
|
// create DPoP token, and add tokens to request
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
}
|
|
2607
|
+
await this._renewTokensIfExpired();
|
|
2608
|
+
dpopPayload = dpopPayload ?? {
|
|
2609
|
+
htu: `${url.origin}${url.pathname}`,
|
|
2610
|
+
htm: method.toUpperCase()
|
|
2611
|
+
};
|
|
2612
|
+
const dpop = await this._createSignedDPoPToken(dpopPayload);
|
|
2613
|
+
// overwrite headers: authorization, dpop
|
|
2614
|
+
headers.set("dpop", dpop);
|
|
2615
|
+
headers.set("authorization", `DPoP ${this.information.tokenDetails.access_token}`);
|
|
2746
2616
|
// check explicitly; to avoid unexpected behaviour
|
|
2747
2617
|
if (input instanceof Request) { // clone the provided request, and override the headers
|
|
2748
2618
|
return fetch(new Request(input, { ...init, headers }));
|
|
@@ -2750,64 +2620,59 @@ class Session {
|
|
|
2750
2620
|
// just override the headers
|
|
2751
2621
|
return fetch(url, { ...init, headers });
|
|
2752
2622
|
}
|
|
2623
|
+
//
|
|
2624
|
+
// Setters
|
|
2625
|
+
//
|
|
2626
|
+
async setTokenDetails(tokenDetails) {
|
|
2627
|
+
this.information.tokenDetails = tokenDetails;
|
|
2628
|
+
await this._updateSessionDetailsFromToken(tokenDetails.access_token);
|
|
2629
|
+
}
|
|
2630
|
+
clearRefreshPromise() {
|
|
2631
|
+
this.refreshPromise = undefined;
|
|
2632
|
+
this.resolveRefresh = undefined;
|
|
2633
|
+
this.rejectRefresh = undefined;
|
|
2634
|
+
}
|
|
2635
|
+
//
|
|
2636
|
+
// Getters
|
|
2637
|
+
//
|
|
2753
2638
|
get isActive() {
|
|
2754
2639
|
return this.isActive_;
|
|
2755
2640
|
}
|
|
2756
2641
|
get webId() {
|
|
2757
2642
|
return this.webId_;
|
|
2758
2643
|
}
|
|
2644
|
+
getExpiresIn() {
|
|
2645
|
+
return this.information.tokenDetails?.expires_in ?? -1;
|
|
2646
|
+
}
|
|
2647
|
+
isExpired() {
|
|
2648
|
+
if (!this.exp_)
|
|
2649
|
+
return true;
|
|
2650
|
+
return this._isTokenExpired(this.exp_);
|
|
2651
|
+
}
|
|
2652
|
+
getTokenDetails() {
|
|
2653
|
+
return this.information.tokenDetails;
|
|
2654
|
+
}
|
|
2759
2655
|
//
|
|
2760
|
-
//
|
|
2656
|
+
// Helpers
|
|
2761
2657
|
//
|
|
2762
2658
|
/**
|
|
2763
|
-
*
|
|
2659
|
+
* Check if the current token is expired (which may happen during device/browser/tab hibernation),
|
|
2660
|
+
* and if expired, restore the session.
|
|
2764
2661
|
*/
|
|
2765
|
-
async
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
}
|
|
2770
|
-
// generate ath
|
|
2771
|
-
this.currentAth_ = await this.computeAth(this.sessionInformation.tokenDetails.access_token);
|
|
2772
|
-
// check for active session
|
|
2773
|
-
this.webId_ = decodeJwt(this.sessionInformation.tokenDetails.access_token)["webid"];
|
|
2774
|
-
this.isActive_ = this.webId !== undefined;
|
|
2775
|
-
// deactivating session when token expire
|
|
2776
|
-
this.setSessionDeactivateTimeout();
|
|
2777
|
-
// refreshing tokens
|
|
2778
|
-
this.setTokenRefreshTimeout();
|
|
2779
|
-
}
|
|
2780
|
-
setSessionDeactivateTimeout() {
|
|
2781
|
-
const deactivate_buffer_seconds = 5;
|
|
2782
|
-
const timeUntilDeactivate = (this.sessionInformation.tokenDetails.expires_in - deactivate_buffer_seconds) * 1000;
|
|
2783
|
-
if (this.sessionDeactivateTimeout)
|
|
2784
|
-
clearTimeout(this.sessionDeactivateTimeout);
|
|
2785
|
-
this.sessionDeactivateTimeout = setTimeout(() => this.logout(), timeUntilDeactivate);
|
|
2786
|
-
}
|
|
2787
|
-
setTokenRefreshTimeout() {
|
|
2788
|
-
const refresh_buffer_seconds = 95;
|
|
2789
|
-
const timeUntilRefresh = (this.sessionInformation.tokenDetails.expires_in - refresh_buffer_seconds) * 1000;
|
|
2790
|
-
if (this.tokenRefreshTimeout)
|
|
2791
|
-
clearTimeout(this.tokenRefreshTimeout);
|
|
2792
|
-
this.tokenRefreshTimeout = setTimeout(async () => {
|
|
2793
|
-
const newTokens = await renewTokens()
|
|
2794
|
-
.catch((error) => {
|
|
2795
|
-
// anything missing or wrong => could not renew tokens.
|
|
2796
|
-
if (this.onSessionExpirationWarning)
|
|
2797
|
-
this.onSessionExpirationWarning();
|
|
2798
|
-
return undefined;
|
|
2799
|
-
});
|
|
2800
|
-
if (!newTokens) {
|
|
2801
|
-
return;
|
|
2662
|
+
async _renewTokensIfExpired() {
|
|
2663
|
+
if (this.isExpired()) {
|
|
2664
|
+
if (!this.refreshPromise) {
|
|
2665
|
+
await this.restore(); // Initiate and wait
|
|
2802
2666
|
}
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2667
|
+
else {
|
|
2668
|
+
await this.refreshPromise; // Wait for already pending
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2806
2671
|
}
|
|
2807
2672
|
/**
|
|
2808
2673
|
* RFC 9449 - Hash of the access token
|
|
2809
2674
|
*/
|
|
2810
|
-
async
|
|
2675
|
+
async _computeAth(accessToken) {
|
|
2811
2676
|
// Convert the ASCII string of the token to a Uint8Array
|
|
2812
2677
|
const encoder = new TextEncoder();
|
|
2813
2678
|
const data = encoder.encode(accessToken); // ASCII by default
|
|
@@ -2823,6 +2688,470 @@ class Session {
|
|
|
2823
2688
|
.replace(/=+$/, '');
|
|
2824
2689
|
return base64url;
|
|
2825
2690
|
}
|
|
2691
|
+
/**
|
|
2692
|
+
* Creates a signed DPoP (Demonstration of Proof-of-Possession) token.
|
|
2693
|
+
*
|
|
2694
|
+
* @param payload The payload to include in the DPoP token. By default, it includes `htu` (HTTP target URI) and `htm` (HTTP method).
|
|
2695
|
+
* @returns A promise that resolves to the signed DPoP token string.
|
|
2696
|
+
* @throws Error if the session has not been initialized - if no token details are available.
|
|
2697
|
+
*/
|
|
2698
|
+
async _createSignedDPoPToken(payload) {
|
|
2699
|
+
if (!this.information.tokenDetails || !this.currentAth_) {
|
|
2700
|
+
throw new Error("Session not established.");
|
|
2701
|
+
}
|
|
2702
|
+
payload.ath = this.currentAth_;
|
|
2703
|
+
const jwk_public_key = await exportJWK(this.information.tokenDetails.dpop_key_pair.publicKey);
|
|
2704
|
+
return new SignJWT(payload)
|
|
2705
|
+
.setIssuedAt()
|
|
2706
|
+
.setJti(window.crypto.randomUUID())
|
|
2707
|
+
.setProtectedHeader({
|
|
2708
|
+
alg: "ES256",
|
|
2709
|
+
typ: "dpop+jwt",
|
|
2710
|
+
jwk: jwk_public_key,
|
|
2711
|
+
})
|
|
2712
|
+
.sign(this.information.tokenDetails.dpop_key_pair.privateKey);
|
|
2713
|
+
}
|
|
2714
|
+
async _updateSessionDetailsFromToken(access_token) {
|
|
2715
|
+
if (!access_token) {
|
|
2716
|
+
await this.logout();
|
|
2717
|
+
return;
|
|
2718
|
+
}
|
|
2719
|
+
try {
|
|
2720
|
+
const decodedToken = decodeJwt(access_token);
|
|
2721
|
+
const webId = decodedToken.webid;
|
|
2722
|
+
if (!webId) {
|
|
2723
|
+
throw new Error('Missing webid claim in access token');
|
|
2724
|
+
}
|
|
2725
|
+
const exp = decodedToken.exp;
|
|
2726
|
+
if (!exp) {
|
|
2727
|
+
throw new Error('Missing exp claim in access token');
|
|
2728
|
+
}
|
|
2729
|
+
this.currentAth_ = await this._computeAth(access_token); // must be done before session set to active
|
|
2730
|
+
this.webId_ = webId;
|
|
2731
|
+
this.exp_ = exp;
|
|
2732
|
+
this.isActive_ = true;
|
|
2733
|
+
}
|
|
2734
|
+
catch (error) {
|
|
2735
|
+
await this.logout();
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
/**
|
|
2739
|
+
* Checks if a JWT expiration timestamp ('exp') has passed.
|
|
2740
|
+
*/
|
|
2741
|
+
_isTokenExpired(exp, bufferSeconds = 0) {
|
|
2742
|
+
if (typeof exp !== 'number' || isNaN(exp)) {
|
|
2743
|
+
return true;
|
|
2744
|
+
}
|
|
2745
|
+
const currentTimeSeconds = Math.floor(Date.now() / 1000);
|
|
2746
|
+
return exp < (currentTimeSeconds + bufferSeconds);
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
// extracting this from Session.ts such that the jest tests would compile :)
|
|
2751
|
+
const getWorkerUrl = () => new URL('./RefreshWorker.js', import.meta.url);
|
|
2752
|
+
|
|
2753
|
+
/**
|
|
2754
|
+
* A simple IndexedDB wrapper.
|
|
2755
|
+
*/
|
|
2756
|
+
class SessionIDB {
|
|
2757
|
+
dbName;
|
|
2758
|
+
storeName;
|
|
2759
|
+
dbVersion;
|
|
2760
|
+
db = null;
|
|
2761
|
+
/**
|
|
2762
|
+
* Creates a new instance
|
|
2763
|
+
* @param dbName The name of the IndexedDB database
|
|
2764
|
+
* @param storeName The name of the object store
|
|
2765
|
+
* @param dbVersion The database version
|
|
2766
|
+
*/
|
|
2767
|
+
constructor(dbName = 'soidc', storeName = 'session', dbVersion = 1) {
|
|
2768
|
+
this.dbName = dbName;
|
|
2769
|
+
this.storeName = storeName;
|
|
2770
|
+
this.dbVersion = dbVersion;
|
|
2771
|
+
}
|
|
2772
|
+
/**
|
|
2773
|
+
* Initializes the IndexedDB database
|
|
2774
|
+
* @returns Promise that resolves when the database is ready
|
|
2775
|
+
*/
|
|
2776
|
+
async init() {
|
|
2777
|
+
return new Promise((resolve, reject) => {
|
|
2778
|
+
const request = indexedDB.open(this.dbName, this.dbVersion);
|
|
2779
|
+
request.onerror = (event) => {
|
|
2780
|
+
reject(new Error(`Database error: ${event.target.error}`));
|
|
2781
|
+
};
|
|
2782
|
+
request.onsuccess = (event) => {
|
|
2783
|
+
this.db = event.target.result;
|
|
2784
|
+
resolve(this);
|
|
2785
|
+
};
|
|
2786
|
+
request.onupgradeneeded = (event) => {
|
|
2787
|
+
const db = event.target.result;
|
|
2788
|
+
// Check if the object store already exists, if not create it
|
|
2789
|
+
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
2790
|
+
db.createObjectStore(this.storeName);
|
|
2791
|
+
}
|
|
2792
|
+
};
|
|
2793
|
+
});
|
|
2794
|
+
}
|
|
2795
|
+
/**
|
|
2796
|
+
* Stores any value in the database with the given ID as key
|
|
2797
|
+
* @param id The identifier/key for the value
|
|
2798
|
+
* @param value The value to store
|
|
2799
|
+
*/
|
|
2800
|
+
async setItem(id, value) {
|
|
2801
|
+
if (!this.db) {
|
|
2802
|
+
await this.init();
|
|
2803
|
+
}
|
|
2804
|
+
return new Promise((resolve, reject) => {
|
|
2805
|
+
const transaction = this.db.transaction(this.storeName, 'readwrite');
|
|
2806
|
+
// Handle transation
|
|
2807
|
+
transaction.oncomplete = () => {
|
|
2808
|
+
resolve();
|
|
2809
|
+
};
|
|
2810
|
+
transaction.onerror = (event) => {
|
|
2811
|
+
reject(new Error(`Transaction error for setItem(${id},...): ${event.target.error}`));
|
|
2812
|
+
};
|
|
2813
|
+
transaction.onabort = (event) => {
|
|
2814
|
+
reject(new Error(`Transaction aborted for setItem(${id},...): ${event.target.error}`));
|
|
2815
|
+
};
|
|
2816
|
+
// Perform the request within the transaction
|
|
2817
|
+
const store = transaction.objectStore(this.storeName);
|
|
2818
|
+
store.put(value, id);
|
|
2819
|
+
});
|
|
2820
|
+
}
|
|
2821
|
+
/**
|
|
2822
|
+
* Retrieves a value from the database by ID
|
|
2823
|
+
* @param id The identifier/key for the value
|
|
2824
|
+
* @returns The stored value or null if not found
|
|
2825
|
+
*/
|
|
2826
|
+
async getItem(id) {
|
|
2827
|
+
if (!this.db) {
|
|
2828
|
+
await this.init();
|
|
2829
|
+
}
|
|
2830
|
+
return new Promise((resolve, reject) => {
|
|
2831
|
+
const transaction = this.db.transaction(this.storeName, 'readonly');
|
|
2832
|
+
// Handle transation
|
|
2833
|
+
transaction.onerror = (event) => {
|
|
2834
|
+
reject(new Error(`Transaction error for getItem(${id}): ${event.target.error}`));
|
|
2835
|
+
};
|
|
2836
|
+
transaction.onabort = (event) => {
|
|
2837
|
+
reject(new Error(`Transaction aborted for getItem(${id}): ${event.target.error}`));
|
|
2838
|
+
};
|
|
2839
|
+
// Perform the request within the transaction
|
|
2840
|
+
const store = transaction.objectStore(this.storeName);
|
|
2841
|
+
const request = store.get(id);
|
|
2842
|
+
request.onsuccess = () => {
|
|
2843
|
+
resolve(request.result || null);
|
|
2844
|
+
};
|
|
2845
|
+
});
|
|
2846
|
+
}
|
|
2847
|
+
/**
|
|
2848
|
+
* Removes an item from the database
|
|
2849
|
+
* @param id The identifier of the item to remove
|
|
2850
|
+
*/
|
|
2851
|
+
async deleteItem(id) {
|
|
2852
|
+
if (!this.db) {
|
|
2853
|
+
await this.init();
|
|
2854
|
+
}
|
|
2855
|
+
return new Promise((resolve, reject) => {
|
|
2856
|
+
const transaction = this.db.transaction(this.storeName, 'readwrite');
|
|
2857
|
+
// Handle transation
|
|
2858
|
+
transaction.oncomplete = () => {
|
|
2859
|
+
resolve();
|
|
2860
|
+
};
|
|
2861
|
+
transaction.onerror = (event) => {
|
|
2862
|
+
reject(new Error(`Transaction error for deleteItem(${id}): ${event.target.error}`));
|
|
2863
|
+
};
|
|
2864
|
+
transaction.onabort = (event) => {
|
|
2865
|
+
reject(new Error(`Transaction aborted for deleteItem(${id}): ${event.target.error}`));
|
|
2866
|
+
};
|
|
2867
|
+
// Perform the request within the transaction
|
|
2868
|
+
const store = transaction.objectStore(this.storeName);
|
|
2869
|
+
store.delete(id);
|
|
2870
|
+
});
|
|
2871
|
+
}
|
|
2872
|
+
/**
|
|
2873
|
+
* Clears all items from the database
|
|
2874
|
+
*/
|
|
2875
|
+
async clear() {
|
|
2876
|
+
if (!this.db) {
|
|
2877
|
+
await this.init();
|
|
2878
|
+
}
|
|
2879
|
+
return new Promise((resolve, reject) => {
|
|
2880
|
+
const transaction = this.db.transaction(this.storeName, 'readwrite');
|
|
2881
|
+
// Handle transation
|
|
2882
|
+
transaction.oncomplete = () => {
|
|
2883
|
+
resolve();
|
|
2884
|
+
};
|
|
2885
|
+
transaction.onerror = (event) => {
|
|
2886
|
+
reject(new Error(`Transaction error for clear(): ${event.target.error}`));
|
|
2887
|
+
};
|
|
2888
|
+
transaction.onabort = (event) => {
|
|
2889
|
+
reject(new Error(`Transaction aborted for clear(): ${event.target.error}`));
|
|
2890
|
+
};
|
|
2891
|
+
// Perform the request within the transaction
|
|
2892
|
+
const store = transaction.objectStore(this.storeName);
|
|
2893
|
+
store.clear();
|
|
2894
|
+
});
|
|
2895
|
+
}
|
|
2896
|
+
/**
|
|
2897
|
+
* Closes the database connection
|
|
2898
|
+
*/
|
|
2899
|
+
close() {
|
|
2900
|
+
if (this.db) {
|
|
2901
|
+
this.db.close();
|
|
2902
|
+
this.db = null;
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
var RefreshMessageTypes;
|
|
2908
|
+
(function (RefreshMessageTypes) {
|
|
2909
|
+
RefreshMessageTypes["SCHEDULE"] = "SCHEDULE";
|
|
2910
|
+
RefreshMessageTypes["REFRESH"] = "REFRESH";
|
|
2911
|
+
RefreshMessageTypes["STOP"] = "STOP";
|
|
2912
|
+
RefreshMessageTypes["DISCONNECT"] = "DISCONNECT";
|
|
2913
|
+
RefreshMessageTypes["TOKEN_DETAILS"] = "TOKEN_DETAILS";
|
|
2914
|
+
RefreshMessageTypes["ERROR_ON_REFRESH"] = "ERROR_ON_REFRESH";
|
|
2915
|
+
RefreshMessageTypes["EXPIRED"] = "EXPIRED";
|
|
2916
|
+
})(RefreshMessageTypes || (RefreshMessageTypes = {}));
|
|
2917
|
+
// A Set to store all connected ports (tabs)
|
|
2918
|
+
const ports = new Set();
|
|
2919
|
+
const broadcast = (message) => {
|
|
2920
|
+
for (const p of ports) {
|
|
2921
|
+
p.postMessage(message);
|
|
2922
|
+
}
|
|
2923
|
+
};
|
|
2924
|
+
let refresher;
|
|
2925
|
+
self.onconnect = (event) => {
|
|
2926
|
+
const port = event.ports[0];
|
|
2927
|
+
ports.add(port);
|
|
2928
|
+
// lazy init
|
|
2929
|
+
if (!refresher) {
|
|
2930
|
+
refresher = new Refresher(broadcast, new SessionIDB());
|
|
2931
|
+
}
|
|
2932
|
+
// handle messages
|
|
2933
|
+
port.onmessage = (event) => {
|
|
2934
|
+
const { type, payload } = event.data;
|
|
2935
|
+
switch (type) {
|
|
2936
|
+
case RefreshMessageTypes.SCHEDULE:
|
|
2937
|
+
refresher.handleSchedule(payload);
|
|
2938
|
+
break;
|
|
2939
|
+
case RefreshMessageTypes.REFRESH:
|
|
2940
|
+
refresher.handleRefresh(port);
|
|
2941
|
+
break;
|
|
2942
|
+
case RefreshMessageTypes.STOP:
|
|
2943
|
+
refresher.handleStop();
|
|
2944
|
+
break;
|
|
2945
|
+
case RefreshMessageTypes.DISCONNECT:
|
|
2946
|
+
ports.delete(port);
|
|
2947
|
+
break;
|
|
2948
|
+
}
|
|
2949
|
+
};
|
|
2950
|
+
port.onmessageerror = () => ports.delete(port);
|
|
2951
|
+
port.start();
|
|
2952
|
+
};
|
|
2953
|
+
class Refresher {
|
|
2954
|
+
tokenDetails;
|
|
2955
|
+
exp;
|
|
2956
|
+
refreshTimeout;
|
|
2957
|
+
finalLogoutTimeout;
|
|
2958
|
+
timersAreRunning = false;
|
|
2959
|
+
broadcast;
|
|
2960
|
+
database;
|
|
2961
|
+
refreshPromise;
|
|
2962
|
+
constructor(broadcast, database) {
|
|
2963
|
+
this.broadcast = broadcast;
|
|
2964
|
+
this.database = database;
|
|
2965
|
+
}
|
|
2966
|
+
async handleSchedule(tokenDetails) {
|
|
2967
|
+
this.tokenDetails = tokenDetails;
|
|
2968
|
+
this.exp = decodeJwt(this.tokenDetails.access_token).exp;
|
|
2969
|
+
this.broadcast({
|
|
2970
|
+
type: RefreshMessageTypes.TOKEN_DETAILS,
|
|
2971
|
+
payload: { tokenDetails: this.tokenDetails }
|
|
2972
|
+
});
|
|
2973
|
+
console.log(`[RefreshWorker] Scheduling timers, expiry in ${this.tokenDetails.expires_in}s`);
|
|
2974
|
+
this.scheduleTimers(this.tokenDetails.expires_in);
|
|
2975
|
+
this.timersAreRunning = true;
|
|
2976
|
+
}
|
|
2977
|
+
async handleRefresh(requestingPort) {
|
|
2978
|
+
if (this.tokenDetails && this.exp && !this.isTokenExpired(this.exp)) {
|
|
2979
|
+
console.log(`[RefreshWorker] Providing current tokens`);
|
|
2980
|
+
requestingPort.postMessage({
|
|
2981
|
+
type: RefreshMessageTypes.TOKEN_DETAILS,
|
|
2982
|
+
payload: { tokenDetails: this.tokenDetails }
|
|
2983
|
+
});
|
|
2984
|
+
}
|
|
2985
|
+
else {
|
|
2986
|
+
console.log(`[RefreshWorker] Refreshing tokens`);
|
|
2987
|
+
this.performRefresh();
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
handleStop() {
|
|
2991
|
+
if (!this.tokenDetails) {
|
|
2992
|
+
console.log('[RefreshWorker] Received STOP, being idle');
|
|
2993
|
+
return;
|
|
2994
|
+
}
|
|
2995
|
+
this.broadcast({ type: RefreshMessageTypes.EXPIRED });
|
|
2996
|
+
this.tokenDetails = undefined;
|
|
2997
|
+
this.exp = undefined;
|
|
2998
|
+
this.refreshPromise = undefined;
|
|
2999
|
+
console.log('[RefreshWorker] Received STOP, clearing timers');
|
|
3000
|
+
this.clearAllTimers();
|
|
3001
|
+
}
|
|
3002
|
+
async performRefresh() {
|
|
3003
|
+
if (this.refreshPromise) {
|
|
3004
|
+
console.log('[RefreshWorker] Refresh already in progress, waiting...');
|
|
3005
|
+
return this.refreshPromise;
|
|
3006
|
+
}
|
|
3007
|
+
this.refreshPromise = this.doRefresh();
|
|
3008
|
+
return this.refreshPromise;
|
|
3009
|
+
}
|
|
3010
|
+
async doRefresh() {
|
|
3011
|
+
try {
|
|
3012
|
+
this.tokenDetails = await renewTokens(this.database);
|
|
3013
|
+
this.exp = decodeJwt(this.tokenDetails.access_token).exp;
|
|
3014
|
+
this.broadcast({
|
|
3015
|
+
type: RefreshMessageTypes.TOKEN_DETAILS,
|
|
3016
|
+
payload: { tokenDetails: this.tokenDetails }
|
|
3017
|
+
});
|
|
3018
|
+
console.log(`[RefreshWorker] Token refreshed`);
|
|
3019
|
+
console.log(`[RefreshWorker] Scheduling timers, expiry in ${this.tokenDetails.expires_in}s`);
|
|
3020
|
+
this.scheduleTimers(this.tokenDetails.expires_in);
|
|
3021
|
+
}
|
|
3022
|
+
catch (error) {
|
|
3023
|
+
this.broadcast({
|
|
3024
|
+
type: RefreshMessageTypes.ERROR_ON_REFRESH,
|
|
3025
|
+
error: error.message
|
|
3026
|
+
});
|
|
3027
|
+
console.log(`[RefreshWorker]`, error.message);
|
|
3028
|
+
}
|
|
3029
|
+
finally {
|
|
3030
|
+
this.refreshPromise = undefined;
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
clearAllTimers() {
|
|
3034
|
+
if (this.refreshTimeout)
|
|
3035
|
+
clearTimeout(this.refreshTimeout);
|
|
3036
|
+
if (this.finalLogoutTimeout)
|
|
3037
|
+
clearTimeout(this.finalLogoutTimeout);
|
|
3038
|
+
this.timersAreRunning = false;
|
|
3039
|
+
}
|
|
3040
|
+
scheduleTimers(expiresIn) {
|
|
3041
|
+
this.clearAllTimers();
|
|
3042
|
+
this.timersAreRunning = true;
|
|
3043
|
+
const expiresInMs = expiresIn * 1000;
|
|
3044
|
+
const REFRESH_THRESHOLD_RATIO = 0.8;
|
|
3045
|
+
const MINIMUM_REFRESH_BUFFER_MS = 30 * 1000;
|
|
3046
|
+
const timeUntilRefresh = REFRESH_THRESHOLD_RATIO * expiresInMs;
|
|
3047
|
+
if (timeUntilRefresh > MINIMUM_REFRESH_BUFFER_MS) {
|
|
3048
|
+
this.refreshTimeout = setTimeout(() => this.performRefresh(), timeUntilRefresh);
|
|
3049
|
+
}
|
|
3050
|
+
const LOGOUT_WARNING_BUFFER_MS = 5 * 1000;
|
|
3051
|
+
const timeUntilLogout = expiresInMs - LOGOUT_WARNING_BUFFER_MS;
|
|
3052
|
+
this.finalLogoutTimeout = setTimeout(() => {
|
|
3053
|
+
this.tokenDetails = undefined;
|
|
3054
|
+
this.broadcast({ type: RefreshMessageTypes.EXPIRED });
|
|
3055
|
+
}, timeUntilLogout);
|
|
3056
|
+
}
|
|
3057
|
+
isTokenExpired(exp, bufferSeconds = 0) {
|
|
3058
|
+
if (typeof exp !== 'number' || isNaN(exp)) {
|
|
3059
|
+
return true;
|
|
3060
|
+
}
|
|
3061
|
+
const currentTimeSeconds = Math.floor(Date.now() / 1000);
|
|
3062
|
+
return exp < (currentTimeSeconds + bufferSeconds);
|
|
3063
|
+
}
|
|
3064
|
+
setTokenDetails(tokenDetails) {
|
|
3065
|
+
this.tokenDetails = tokenDetails;
|
|
3066
|
+
}
|
|
3067
|
+
// For testing
|
|
3068
|
+
getTimersAreRunning() { return this.timersAreRunning; }
|
|
3069
|
+
getTokenDetails() { return this.tokenDetails; }
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
/**
|
|
3073
|
+
* This Session provides background token refreshing using a Web Worker.
|
|
3074
|
+
*/
|
|
3075
|
+
class WebWorkerSession extends SessionCore {
|
|
3076
|
+
worker;
|
|
3077
|
+
onSessionExpirationWarning;
|
|
3078
|
+
onSessionExpiration;
|
|
3079
|
+
constructor(clientDetails, sessionOptions) {
|
|
3080
|
+
const database = new SessionIDB();
|
|
3081
|
+
const options = { ...sessionOptions, database };
|
|
3082
|
+
super(clientDetails, options);
|
|
3083
|
+
this.onSessionExpirationWarning = sessionOptions?.onSessionExpirationWarning;
|
|
3084
|
+
this.onSessionExpiration = sessionOptions?.onSessionExpiration;
|
|
3085
|
+
// Allow consumer to provide worker URL, or use default
|
|
3086
|
+
const workerUrl = sessionOptions?.workerUrl ?? getWorkerUrl();
|
|
3087
|
+
this.worker = new SharedWorker(workerUrl, { type: 'module' });
|
|
3088
|
+
this.worker.port.onmessage = (event) => {
|
|
3089
|
+
this.handleWorkerMessage(event.data).catch(console.error);
|
|
3090
|
+
};
|
|
3091
|
+
window.addEventListener('beforeunload', () => {
|
|
3092
|
+
this.worker.port.postMessage({ type: RefreshMessageTypes.DISCONNECT });
|
|
3093
|
+
});
|
|
3094
|
+
}
|
|
3095
|
+
async handleWorkerMessage(data) {
|
|
3096
|
+
const { type, payload, error } = data;
|
|
3097
|
+
switch (type) {
|
|
3098
|
+
case RefreshMessageTypes.TOKEN_DETAILS:
|
|
3099
|
+
const wasActive = this.isActive;
|
|
3100
|
+
await this.setTokenDetails(payload.tokenDetails);
|
|
3101
|
+
if (wasActive !== this.isActive)
|
|
3102
|
+
this.onSessionStateChange?.();
|
|
3103
|
+
if (this.refreshPromise && this.resolveRefresh) {
|
|
3104
|
+
this.resolveRefresh();
|
|
3105
|
+
this.clearRefreshPromise();
|
|
3106
|
+
}
|
|
3107
|
+
break;
|
|
3108
|
+
case RefreshMessageTypes.ERROR_ON_REFRESH:
|
|
3109
|
+
if (this.isActive)
|
|
3110
|
+
this.onSessionExpirationWarning?.();
|
|
3111
|
+
if (this.refreshPromise && this.rejectRefresh) {
|
|
3112
|
+
if (this.isActive) {
|
|
3113
|
+
this.rejectRefresh(new Error(error || 'Token refresh failed'));
|
|
3114
|
+
}
|
|
3115
|
+
else {
|
|
3116
|
+
this.rejectRefresh(new Error("No session to restore"));
|
|
3117
|
+
}
|
|
3118
|
+
this.clearRefreshPromise();
|
|
3119
|
+
}
|
|
3120
|
+
break;
|
|
3121
|
+
case RefreshMessageTypes.EXPIRED:
|
|
3122
|
+
if (this.isActive) {
|
|
3123
|
+
this.onSessionExpiration?.();
|
|
3124
|
+
await this.logout();
|
|
3125
|
+
}
|
|
3126
|
+
if (this.refreshPromise && this.rejectRefresh) {
|
|
3127
|
+
this.rejectRefresh(new Error(error || 'Token refresh failed'));
|
|
3128
|
+
this.clearRefreshPromise();
|
|
3129
|
+
}
|
|
3130
|
+
break;
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
;
|
|
3134
|
+
async handleRedirectFromLogin() {
|
|
3135
|
+
await super.handleRedirectFromLogin();
|
|
3136
|
+
if (this.isActive) { // If login was successful, tell the worker to schedule refreshing
|
|
3137
|
+
this.worker.port.postMessage({ type: RefreshMessageTypes.SCHEDULE, payload: this.getTokenDetails() });
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
async restore() {
|
|
3141
|
+
if (this.refreshPromise) {
|
|
3142
|
+
return this.refreshPromise;
|
|
3143
|
+
}
|
|
3144
|
+
this.refreshPromise = new Promise((resolve, reject) => {
|
|
3145
|
+
this.resolveRefresh = resolve;
|
|
3146
|
+
this.rejectRefresh = reject;
|
|
3147
|
+
});
|
|
3148
|
+
this.worker.port.postMessage({ type: RefreshMessageTypes.REFRESH });
|
|
3149
|
+
return this.refreshPromise;
|
|
3150
|
+
}
|
|
3151
|
+
async logout() {
|
|
3152
|
+
this.worker.port.postMessage({ type: RefreshMessageTypes.STOP });
|
|
3153
|
+
await super.logout();
|
|
3154
|
+
}
|
|
2826
3155
|
}
|
|
2827
3156
|
|
|
2828
3157
|
class BrowserSession {
|
|
@@ -2835,7 +3164,7 @@ class BrowserSession {
|
|
|
2835
3164
|
isLoggedIn: false,
|
|
2836
3165
|
webId: undefined,
|
|
2837
3166
|
});
|
|
2838
|
-
this.session = new
|
|
3167
|
+
this.session = new WebWorkerSession();
|
|
2839
3168
|
this._authenticatedFetch = this.session.authFetch.bind(this.session);
|
|
2840
3169
|
}
|
|
2841
3170
|
async handleIncomingRedirect(restorePreviousSession = false) {
|