@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
|
@@ -2608,160 +2608,6 @@ const requestDynamicClientRegistration = async (registration_endpoint, client_de
|
|
|
2608
2608
|
});
|
|
2609
2609
|
};
|
|
2610
2610
|
|
|
2611
|
-
/**
|
|
2612
|
-
* A simple IndexedDB wrapper
|
|
2613
|
-
*/
|
|
2614
|
-
class SessionDatabase {
|
|
2615
|
-
dbName;
|
|
2616
|
-
storeName;
|
|
2617
|
-
dbVersion;
|
|
2618
|
-
db = null;
|
|
2619
|
-
/**
|
|
2620
|
-
* Creates a new instance
|
|
2621
|
-
* @param dbName The name of the IndexedDB database
|
|
2622
|
-
* @param storeName The name of the object store
|
|
2623
|
-
* @param dbVersion The database version
|
|
2624
|
-
*/
|
|
2625
|
-
constructor(dbName = 'soidc', storeName = 'session', dbVersion = 1) {
|
|
2626
|
-
this.dbName = dbName;
|
|
2627
|
-
this.storeName = storeName;
|
|
2628
|
-
this.dbVersion = dbVersion;
|
|
2629
|
-
}
|
|
2630
|
-
/**
|
|
2631
|
-
* Initializes the IndexedDB database
|
|
2632
|
-
* @returns Promise that resolves when the database is ready
|
|
2633
|
-
*/
|
|
2634
|
-
async init() {
|
|
2635
|
-
return new Promise((resolve, reject) => {
|
|
2636
|
-
const request = indexedDB.open(this.dbName, this.dbVersion);
|
|
2637
|
-
request.onerror = (event) => {
|
|
2638
|
-
reject(new Error(`Database error: ${event.target.error}`));
|
|
2639
|
-
};
|
|
2640
|
-
request.onsuccess = (event) => {
|
|
2641
|
-
this.db = event.target.result;
|
|
2642
|
-
resolve(this);
|
|
2643
|
-
};
|
|
2644
|
-
request.onupgradeneeded = (event) => {
|
|
2645
|
-
const db = event.target.result;
|
|
2646
|
-
// Check if the object store already exists, if not create it
|
|
2647
|
-
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
2648
|
-
db.createObjectStore(this.storeName);
|
|
2649
|
-
}
|
|
2650
|
-
};
|
|
2651
|
-
});
|
|
2652
|
-
}
|
|
2653
|
-
/**
|
|
2654
|
-
* Stores any value in the database with the given ID as key
|
|
2655
|
-
* @param id The identifier/key for the value
|
|
2656
|
-
* @param value The value to store
|
|
2657
|
-
*/
|
|
2658
|
-
async setItem(id, value) {
|
|
2659
|
-
if (!this.db) {
|
|
2660
|
-
await this.init();
|
|
2661
|
-
}
|
|
2662
|
-
return new Promise((resolve, reject) => {
|
|
2663
|
-
const transaction = this.db.transaction(this.storeName, 'readwrite');
|
|
2664
|
-
// Handle transation
|
|
2665
|
-
transaction.oncomplete = () => {
|
|
2666
|
-
resolve();
|
|
2667
|
-
};
|
|
2668
|
-
transaction.onerror = (event) => {
|
|
2669
|
-
reject(new Error(`Transaction error for setItem(${id},...): ${event.target.error}`));
|
|
2670
|
-
};
|
|
2671
|
-
transaction.onabort = (event) => {
|
|
2672
|
-
reject(new Error(`Transaction aborted for setItem(${id},...): ${event.target.error}`));
|
|
2673
|
-
};
|
|
2674
|
-
// Perform the request within the transaction
|
|
2675
|
-
const store = transaction.objectStore(this.storeName);
|
|
2676
|
-
store.put(value, id);
|
|
2677
|
-
});
|
|
2678
|
-
}
|
|
2679
|
-
/**
|
|
2680
|
-
* Retrieves a value from the database by ID
|
|
2681
|
-
* @param id The identifier/key for the value
|
|
2682
|
-
* @returns The stored value or null if not found
|
|
2683
|
-
*/
|
|
2684
|
-
async getItem(id) {
|
|
2685
|
-
if (!this.db) {
|
|
2686
|
-
await this.init();
|
|
2687
|
-
}
|
|
2688
|
-
return new Promise((resolve, reject) => {
|
|
2689
|
-
const transaction = this.db.transaction(this.storeName, 'readonly');
|
|
2690
|
-
// Handle transation
|
|
2691
|
-
transaction.onerror = (event) => {
|
|
2692
|
-
reject(new Error(`Transaction error for getItem(${id}): ${event.target.error}`));
|
|
2693
|
-
};
|
|
2694
|
-
transaction.onabort = (event) => {
|
|
2695
|
-
reject(new Error(`Transaction aborted for getItem(${id}): ${event.target.error}`));
|
|
2696
|
-
};
|
|
2697
|
-
// Perform the request within the transaction
|
|
2698
|
-
const store = transaction.objectStore(this.storeName);
|
|
2699
|
-
const request = store.get(id);
|
|
2700
|
-
request.onsuccess = () => {
|
|
2701
|
-
resolve(request.result || null);
|
|
2702
|
-
};
|
|
2703
|
-
});
|
|
2704
|
-
}
|
|
2705
|
-
/**
|
|
2706
|
-
* Removes an item from the database
|
|
2707
|
-
* @param id The identifier of the item to remove
|
|
2708
|
-
*/
|
|
2709
|
-
async deleteItem(id) {
|
|
2710
|
-
if (!this.db) {
|
|
2711
|
-
await this.init();
|
|
2712
|
-
}
|
|
2713
|
-
return new Promise((resolve, reject) => {
|
|
2714
|
-
const transaction = this.db.transaction(this.storeName, 'readwrite');
|
|
2715
|
-
// Handle transation
|
|
2716
|
-
transaction.oncomplete = () => {
|
|
2717
|
-
resolve();
|
|
2718
|
-
};
|
|
2719
|
-
transaction.onerror = (event) => {
|
|
2720
|
-
reject(new Error(`Transaction error for deleteItem(${id}): ${event.target.error}`));
|
|
2721
|
-
};
|
|
2722
|
-
transaction.onabort = (event) => {
|
|
2723
|
-
reject(new Error(`Transaction aborted for deleteItem(${id}): ${event.target.error}`));
|
|
2724
|
-
};
|
|
2725
|
-
// Perform the request within the transaction
|
|
2726
|
-
const store = transaction.objectStore(this.storeName);
|
|
2727
|
-
store.delete(id);
|
|
2728
|
-
});
|
|
2729
|
-
}
|
|
2730
|
-
/**
|
|
2731
|
-
* Clears all items from the database
|
|
2732
|
-
*/
|
|
2733
|
-
async clear() {
|
|
2734
|
-
if (!this.db) {
|
|
2735
|
-
await this.init();
|
|
2736
|
-
}
|
|
2737
|
-
return new Promise((resolve, reject) => {
|
|
2738
|
-
const transaction = this.db.transaction(this.storeName, 'readwrite');
|
|
2739
|
-
// Handle transation
|
|
2740
|
-
transaction.oncomplete = () => {
|
|
2741
|
-
resolve();
|
|
2742
|
-
};
|
|
2743
|
-
transaction.onerror = (event) => {
|
|
2744
|
-
reject(new Error(`Transaction error for clear(): ${event.target.error}`));
|
|
2745
|
-
};
|
|
2746
|
-
transaction.onabort = (event) => {
|
|
2747
|
-
reject(new Error(`Transaction aborted for clear(): ${event.target.error}`));
|
|
2748
|
-
};
|
|
2749
|
-
// Perform the request within the transaction
|
|
2750
|
-
const store = transaction.objectStore(this.storeName);
|
|
2751
|
-
store.clear();
|
|
2752
|
-
});
|
|
2753
|
-
}
|
|
2754
|
-
/**
|
|
2755
|
-
* Closes the database connection
|
|
2756
|
-
*/
|
|
2757
|
-
close() {
|
|
2758
|
-
if (this.db) {
|
|
2759
|
-
this.db.close();
|
|
2760
|
-
this.db = null;
|
|
2761
|
-
}
|
|
2762
|
-
}
|
|
2763
|
-
}
|
|
2764
|
-
|
|
2765
2611
|
/**
|
|
2766
2612
|
* Login with the idp, using a provided `client_id` or dynamic client registration if none provided.
|
|
2767
2613
|
*
|
|
@@ -2785,7 +2631,7 @@ const redirectForLogin = async (idp, redirect_uri, client_details) => {
|
|
|
2785
2631
|
const issuer = openid_configuration["issuer"];
|
|
2786
2632
|
const trim_trailing_slash = (url) => (url.endsWith('/') ? url.slice(0, -1) : url);
|
|
2787
2633
|
if (trim_trailing_slash(idp) !== trim_trailing_slash(issuer)) { // expected idp matches received issuer mod trailing slash?
|
|
2788
|
-
throw new Error("RFC 9207 - iss
|
|
2634
|
+
throw new Error("RFC 9207 - iss !== idp - " + issuer + " !== " + idp);
|
|
2789
2635
|
}
|
|
2790
2636
|
sessionStorage.setItem("idp", issuer);
|
|
2791
2637
|
// remember token endpoint
|
|
@@ -2806,7 +2652,12 @@ const redirectForLogin = async (idp, redirect_uri, client_details) => {
|
|
|
2806
2652
|
return response.json();
|
|
2807
2653
|
});
|
|
2808
2654
|
client_id = client_registration["client_id"];
|
|
2809
|
-
|
|
2655
|
+
}
|
|
2656
|
+
// remember client_id if not URL
|
|
2657
|
+
try {
|
|
2658
|
+
new URL(client_id);
|
|
2659
|
+
}
|
|
2660
|
+
catch {
|
|
2810
2661
|
sessionStorage.setItem("client_id", client_id);
|
|
2811
2662
|
}
|
|
2812
2663
|
// RFC 7636 PKCE, remember code verifer
|
|
@@ -2847,7 +2698,7 @@ const getPKCEcode = async () => {
|
|
|
2847
2698
|
* URL contains authrization code, issuer (idp) and state (csrf token),
|
|
2848
2699
|
* get an access token for the authrization code.
|
|
2849
2700
|
*/
|
|
2850
|
-
const onIncomingRedirect = async (client_details) => {
|
|
2701
|
+
const onIncomingRedirect = async (client_details, database) => {
|
|
2851
2702
|
const url = new URL(window.location.href);
|
|
2852
2703
|
// authorization code
|
|
2853
2704
|
const authorization_code = url.searchParams.get("code");
|
|
@@ -2857,12 +2708,12 @@ const onIncomingRedirect = async (client_details) => {
|
|
|
2857
2708
|
}
|
|
2858
2709
|
// RFC 9207 issuer check
|
|
2859
2710
|
const idp = sessionStorage.getItem("idp");
|
|
2860
|
-
if (idp === null || url.searchParams.get("iss")
|
|
2861
|
-
throw new Error("RFC 9207 - iss
|
|
2711
|
+
if (idp === null || url.searchParams.get("iss") !== idp) {
|
|
2712
|
+
throw new Error("RFC 9207 - iss !== idp - " + url.searchParams.get("iss") + " !== " + idp);
|
|
2862
2713
|
}
|
|
2863
2714
|
// RFC 6749 OAuth 2.0
|
|
2864
|
-
if (url.searchParams.get("state")
|
|
2865
|
-
throw new Error("RFC 6749 - state
|
|
2715
|
+
if (url.searchParams.get("state") !== sessionStorage.getItem("csrf_token")) {
|
|
2716
|
+
throw new Error("RFC 6749 - state !== csrf_token - " + url.searchParams.get("state") + " !== " + sessionStorage.getItem("csrf_token"));
|
|
2866
2717
|
}
|
|
2867
2718
|
// remove redirect query parameters from URL
|
|
2868
2719
|
url.searchParams.delete("iss");
|
|
@@ -2906,30 +2757,32 @@ const onIncomingRedirect = async (client_details) => {
|
|
|
2906
2757
|
});
|
|
2907
2758
|
// check dpop thumbprint
|
|
2908
2759
|
const dpopThumbprint = await calculateJwkThumbprint(await exportJWK(key_pair.publicKey));
|
|
2909
|
-
if (payload["cnf"]["jkt"]
|
|
2910
|
-
throw new Error("Access Token validation failed on `jkt`: jkt
|
|
2760
|
+
if (payload["cnf"]["jkt"] !== dpopThumbprint) {
|
|
2761
|
+
throw new Error("Access Token validation failed on `jkt`: jkt !== DPoP thumbprint - " + payload["cnf"]["jkt"] + " !== " + dpopThumbprint);
|
|
2911
2762
|
}
|
|
2912
2763
|
// check client_id
|
|
2913
|
-
if (payload["client_id"]
|
|
2914
|
-
throw new Error("Access Token validation failed on `client_id`: JWT payload
|
|
2764
|
+
if (payload["client_id"] !== client_id) {
|
|
2765
|
+
throw new Error("Access Token validation failed on `client_id`: JWT payload !== client_id - " + payload["client_id"] + " !== " + client_id);
|
|
2915
2766
|
}
|
|
2916
2767
|
// summarise session info
|
|
2917
2768
|
const token_details = { ...token_response, dpop_key_pair: key_pair };
|
|
2918
2769
|
const idp_details = { idp, jwks_uri, token_endpoint };
|
|
2919
2770
|
if (!client_details)
|
|
2920
|
-
client_details = { redirect_uris: [
|
|
2771
|
+
client_details = { redirect_uris: [url.toString()] };
|
|
2921
2772
|
client_details.client_id = client_id;
|
|
2922
|
-
//
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2773
|
+
// and persist refresh token details
|
|
2774
|
+
if (database) {
|
|
2775
|
+
await database.init();
|
|
2776
|
+
await Promise.all([
|
|
2777
|
+
database.setItem("idp", idp),
|
|
2778
|
+
database.setItem("jwks_uri", jwks_uri),
|
|
2779
|
+
database.setItem("token_endpoint", token_endpoint),
|
|
2780
|
+
database.setItem("client_id", client_id),
|
|
2781
|
+
database.setItem("dpop_keypair", key_pair),
|
|
2782
|
+
database.setItem("refresh_token", token_response["refresh_token"])
|
|
2783
|
+
]);
|
|
2784
|
+
database.close();
|
|
2785
|
+
}
|
|
2933
2786
|
// clean session storage
|
|
2934
2787
|
sessionStorage.removeItem("csrf_token");
|
|
2935
2788
|
sessionStorage.removeItem("pkce_code_verifier");
|
|
@@ -2987,56 +2840,60 @@ const requestAccessToken = async (authorization_code, pkce_code_verifier, redire
|
|
|
2987
2840
|
});
|
|
2988
2841
|
};
|
|
2989
2842
|
|
|
2990
|
-
const renewTokens = async () => {
|
|
2843
|
+
const renewTokens = async (sessionDatabase) => {
|
|
2991
2844
|
// remember session details
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
2845
|
+
try {
|
|
2846
|
+
await sessionDatabase.init();
|
|
2847
|
+
const client_id = await sessionDatabase.getItem("client_id");
|
|
2848
|
+
const token_endpoint = await sessionDatabase.getItem("token_endpoint");
|
|
2849
|
+
const key_pair = await sessionDatabase.getItem("dpop_keypair");
|
|
2850
|
+
const refresh_token = await sessionDatabase.getItem("refresh_token");
|
|
2851
|
+
if (client_id === null || token_endpoint === null || key_pair === null || refresh_token === null) {
|
|
2852
|
+
// we can not restore the old session
|
|
2853
|
+
throw new Error("Could not refresh tokens: details missing from database.");
|
|
2854
|
+
}
|
|
2855
|
+
const token_response = await requestFreshTokens(refresh_token, client_id, token_endpoint, key_pair)
|
|
2856
|
+
.then((response) => {
|
|
2857
|
+
if (!response.ok) {
|
|
2858
|
+
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
2859
|
+
}
|
|
2860
|
+
return response.json();
|
|
2861
|
+
});
|
|
2862
|
+
// verify access_token // ! Solid-OIDC specification says it should be a dpop-bound `id token` but implementations provide a dpop-bound `access token`
|
|
2863
|
+
const accessToken = token_response["access_token"];
|
|
2864
|
+
const idp = await sessionDatabase.getItem("idp");
|
|
2865
|
+
if (idp === null) {
|
|
2866
|
+
throw new Error("Access Token validation preparation - Could not find in sessionDatabase: idp");
|
|
2867
|
+
}
|
|
2868
|
+
const jwks_uri = await sessionDatabase.getItem("jwks_uri");
|
|
2869
|
+
if (jwks_uri === null) {
|
|
2870
|
+
throw new Error("Access Token validation preparation - Could not find in sessionDatabase: jwks_uri");
|
|
2871
|
+
}
|
|
2872
|
+
const jwks = createRemoteJWKSet(new URL(jwks_uri));
|
|
2873
|
+
const { payload } = await jwtVerify(accessToken, jwks, {
|
|
2874
|
+
issuer: idp, // RFC 9207
|
|
2875
|
+
audience: "solid", // RFC 7519 // ! "solid" as per implementations ...
|
|
2876
|
+
// exp, nbf, iat - handled automatically
|
|
2877
|
+
});
|
|
2878
|
+
// check dpop thumbprint
|
|
2879
|
+
const dpopThumbprint = await calculateJwkThumbprint(await exportJWK(key_pair.publicKey));
|
|
2880
|
+
if (payload["cnf"]["jkt"] !== dpopThumbprint) {
|
|
2881
|
+
throw new Error("Access Token validation failed on `jkt`: jkt !== DPoP thumbprint - " + payload["cnf"]["jkt"] + " !== " + dpopThumbprint);
|
|
3005
2882
|
}
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
throw new Error("Access Token validation preparation - Could not find in sessionDatabase: jwks_uri");
|
|
3017
|
-
}
|
|
3018
|
-
const jwks = createRemoteJWKSet(new URL(jwks_uri));
|
|
3019
|
-
const { payload } = await jwtVerify(accessToken, jwks, {
|
|
3020
|
-
issuer: idp, // RFC 9207
|
|
3021
|
-
audience: "solid", // RFC 7519 // ! "solid" as per implementations ...
|
|
3022
|
-
// exp, nbf, iat - handled automatically
|
|
3023
|
-
});
|
|
3024
|
-
// check dpop thumbprint
|
|
3025
|
-
const dpopThumbprint = await calculateJwkThumbprint(await exportJWK(key_pair.publicKey));
|
|
3026
|
-
if (payload["cnf"]["jkt"] != dpopThumbprint) {
|
|
3027
|
-
throw new Error("Access Token validation failed on `jkt`: jkt != DPoP thumbprint - " + payload["cnf"]["jkt"] + " != " + dpopThumbprint);
|
|
2883
|
+
// check client_id
|
|
2884
|
+
if (payload["client_id"] !== client_id) {
|
|
2885
|
+
throw new Error("Access Token validation failed on `client_id`: JWT payload !== client_id - " + payload["client_id"] + " !== " + client_id);
|
|
2886
|
+
}
|
|
2887
|
+
// set new refresh token for token rotation
|
|
2888
|
+
await sessionDatabase.setItem("refresh_token", token_response["refresh_token"]);
|
|
2889
|
+
return {
|
|
2890
|
+
...token_response,
|
|
2891
|
+
dpop_key_pair: key_pair,
|
|
2892
|
+
};
|
|
3028
2893
|
}
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
throw new Error("Access Token validation failed on `client_id`: JWT payload != client_id - " + payload["client_id"] + " != " + client_id);
|
|
2894
|
+
finally {
|
|
2895
|
+
sessionDatabase.close();
|
|
3032
2896
|
}
|
|
3033
|
-
// set new refresh token for token rotation
|
|
3034
|
-
await sessionDatabase.setItem("refresh_token", token_response["refresh_token"]);
|
|
3035
|
-
sessionDatabase.close();
|
|
3036
|
-
return {
|
|
3037
|
-
...token_response,
|
|
3038
|
-
dpop_key_pair: key_pair,
|
|
3039
|
-
};
|
|
3040
2897
|
};
|
|
3041
2898
|
/**
|
|
3042
2899
|
* Request an dpop-bound access token from a token endpoint using a refresh token
|
|
@@ -3058,7 +2915,7 @@ const requestFreshTokens = async (refresh_token, client_id, token_endpoint, key_
|
|
|
3058
2915
|
htm: "POST",
|
|
3059
2916
|
})
|
|
3060
2917
|
.setIssuedAt()
|
|
3061
|
-
.setJti(
|
|
2918
|
+
.setJti(self.crypto.randomUUID())
|
|
3062
2919
|
.setProtectedHeader({
|
|
3063
2920
|
alg: "ES256",
|
|
3064
2921
|
typ: "dpop+jwt",
|
|
@@ -3079,113 +2936,118 @@ const requestFreshTokens = async (refresh_token, client_id, token_endpoint, key_
|
|
|
3079
2936
|
});
|
|
3080
2937
|
};
|
|
3081
2938
|
|
|
3082
|
-
|
|
3083
|
-
|
|
2939
|
+
//
|
|
2940
|
+
//
|
|
2941
|
+
// Basic implementation
|
|
2942
|
+
//
|
|
2943
|
+
//
|
|
2944
|
+
/**
|
|
2945
|
+
* The SessionCore class manages session state and core logic but does not handle the refresh lifecycle.
|
|
2946
|
+
* It receives {@link SessionOptions} with a database to be able to restore a session.
|
|
2947
|
+
* That database can be re-used by (your!) surrounding implementation to handle the refresh lifecycle.
|
|
2948
|
+
* 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.
|
|
2949
|
+
*
|
|
2950
|
+
* If you are building a web app, use the Session implementation provided in the default `/web` version of this library.
|
|
2951
|
+
*/
|
|
2952
|
+
class SessionCore {
|
|
3084
2953
|
isActive_ = false;
|
|
2954
|
+
exp_;
|
|
3085
2955
|
webId_ = undefined;
|
|
3086
2956
|
currentAth_ = undefined;
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
2957
|
+
onSessionStateChange;
|
|
2958
|
+
information;
|
|
2959
|
+
database;
|
|
2960
|
+
refreshPromise;
|
|
2961
|
+
resolveRefresh;
|
|
2962
|
+
rejectRefresh;
|
|
3093
2963
|
constructor(clientDetails, sessionOptions) {
|
|
3094
|
-
this.
|
|
3095
|
-
this.
|
|
2964
|
+
this.information = { clientDetails };
|
|
2965
|
+
this.database = sessionOptions?.database;
|
|
2966
|
+
this.onSessionStateChange = sessionOptions?.onSessionStateChange;
|
|
3096
2967
|
}
|
|
3097
|
-
/**
|
|
3098
|
-
* Redirect the user for login to their IDP.
|
|
3099
|
-
*
|
|
3100
|
-
* @throws Error if the session has not been initialized.
|
|
3101
|
-
*/
|
|
3102
2968
|
async login(idp, redirect_uri) {
|
|
3103
|
-
await redirectForLogin(idp, redirect_uri, this.
|
|
3104
|
-
}
|
|
3105
|
-
/**
|
|
3106
|
-
* Clears all session-related information, including IDP details and tokens.
|
|
3107
|
-
* This logs the user out.
|
|
3108
|
-
* Client details are preserved.
|
|
3109
|
-
*/
|
|
3110
|
-
async logout() {
|
|
3111
|
-
// clear timeouts
|
|
3112
|
-
if (this.sessionDeactivateTimeout)
|
|
3113
|
-
clearTimeout(this.sessionDeactivateTimeout);
|
|
3114
|
-
this.sessionDeactivateTimeout = undefined;
|
|
3115
|
-
if (this.tokenRefreshTimeout)
|
|
3116
|
-
clearTimeout(this.tokenRefreshTimeout);
|
|
3117
|
-
this.tokenRefreshTimeout = undefined;
|
|
3118
|
-
// clean session data
|
|
3119
|
-
this.sessionInformation.idpDetails = undefined;
|
|
3120
|
-
this.sessionInformation.tokenDetails = undefined;
|
|
3121
|
-
this.isActive_ = false;
|
|
3122
|
-
this.webId_ = undefined;
|
|
3123
|
-
// only preserve client_id if URI
|
|
3124
|
-
if (this.sessionInformation.clientDetails?.client_id)
|
|
3125
|
-
try {
|
|
3126
|
-
new URL(this.sessionInformation.clientDetails.client_id);
|
|
3127
|
-
}
|
|
3128
|
-
catch (_) {
|
|
3129
|
-
this.sessionInformation.clientDetails.client_id = undefined;
|
|
3130
|
-
}
|
|
3131
|
-
// clean session database
|
|
3132
|
-
const sessionDatabase = await new SessionDatabase().init();
|
|
3133
|
-
await sessionDatabase.clear();
|
|
3134
|
-
sessionDatabase.close();
|
|
2969
|
+
await redirectForLogin(idp, redirect_uri, this.information.clientDetails);
|
|
3135
2970
|
}
|
|
3136
2971
|
/**
|
|
3137
2972
|
* Handles the redirect from the identity provider after a login attempt.
|
|
3138
2973
|
* It attempts to retrieve tokens using the authorization code.
|
|
2974
|
+
* Upon success, it tries to persist information to refresh tokens in the session database.
|
|
2975
|
+
* If no database was provided, no information is persisted.
|
|
3139
2976
|
*/
|
|
3140
2977
|
async handleRedirectFromLogin() {
|
|
3141
2978
|
// Redirect after Authorization Code Grant // memory via sessionStorage
|
|
3142
|
-
const newSessionInfo = await onIncomingRedirect(this.
|
|
2979
|
+
const newSessionInfo = await onIncomingRedirect(this.information.clientDetails, this.database);
|
|
3143
2980
|
// no session - we remain unauthenticated
|
|
3144
|
-
if (!newSessionInfo.tokenDetails)
|
|
2981
|
+
if (!newSessionInfo.tokenDetails)
|
|
3145
2982
|
return;
|
|
3146
|
-
}
|
|
3147
2983
|
// we got a session
|
|
3148
|
-
this.
|
|
3149
|
-
|
|
2984
|
+
this.information.clientDetails = newSessionInfo.clientDetails;
|
|
2985
|
+
this.information.idpDetails = newSessionInfo.idpDetails;
|
|
2986
|
+
await this.setTokenDetails(newSessionInfo.tokenDetails);
|
|
2987
|
+
// callback state change
|
|
2988
|
+
this.onSessionStateChange?.(); // we logged in
|
|
3150
2989
|
}
|
|
3151
2990
|
/**
|
|
3152
2991
|
* Handles session restoration using the refresh token grant.
|
|
3153
2992
|
* Silently fails if session could not be restored (maybe there was no session in the first place).
|
|
3154
2993
|
*/
|
|
3155
2994
|
async restore() {
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
this.
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
2995
|
+
if (!this.database) {
|
|
2996
|
+
throw new Error("Could not refresh tokens: missing database. Provide database in sessionOption.");
|
|
2997
|
+
}
|
|
2998
|
+
if (this.refreshPromise) {
|
|
2999
|
+
return this.refreshPromise;
|
|
3000
|
+
}
|
|
3001
|
+
this.refreshPromise = new Promise((resolve, reject) => {
|
|
3002
|
+
this.resolveRefresh = resolve;
|
|
3003
|
+
this.rejectRefresh = reject;
|
|
3004
|
+
});
|
|
3005
|
+
// Restore session using Refresh Token Grant
|
|
3006
|
+
const wasActive = this.isActive;
|
|
3007
|
+
renewTokens(this.database)
|
|
3008
|
+
.then(tokenDetails => this.setTokenDetails(tokenDetails))
|
|
3009
|
+
.then(() => this.resolveRefresh())
|
|
3010
|
+
.catch(error => {
|
|
3011
|
+
if (this.isActive) {
|
|
3012
|
+
this.rejectRefresh(new Error(error || 'Token refresh failed'));
|
|
3013
|
+
// do not change state (yet), let the app decide if they want to logout or if they just want to retry.
|
|
3014
|
+
}
|
|
3015
|
+
else {
|
|
3016
|
+
this.rejectRefresh(new Error("No session to restore."));
|
|
3017
|
+
}
|
|
3018
|
+
}).finally(() => {
|
|
3019
|
+
this.clearRefreshPromise();
|
|
3020
|
+
if (wasActive !== this.isActive)
|
|
3021
|
+
this.onSessionStateChange?.();
|
|
3022
|
+
});
|
|
3023
|
+
return this.refreshPromise;
|
|
3166
3024
|
}
|
|
3167
3025
|
/**
|
|
3168
|
-
*
|
|
3169
|
-
*
|
|
3170
|
-
*
|
|
3171
|
-
* @returns A promise that resolves to the signed DPoP token string.
|
|
3172
|
-
* @throws Error if the session has not been initialized - if no token details are available.
|
|
3026
|
+
* This logs the user out.
|
|
3027
|
+
* Clears all session-related information, including IDP details and tokens.
|
|
3028
|
+
* Client ID is preserved if it is a URI.
|
|
3173
3029
|
*/
|
|
3174
|
-
async
|
|
3175
|
-
|
|
3176
|
-
|
|
3030
|
+
async logout() {
|
|
3031
|
+
// clean session data
|
|
3032
|
+
this.isActive_ = false;
|
|
3033
|
+
this.exp_ = undefined;
|
|
3034
|
+
this.webId_ = undefined;
|
|
3035
|
+
this.currentAth_ = undefined;
|
|
3036
|
+
this.information.idpDetails = undefined;
|
|
3037
|
+
this.information.tokenDetails = undefined;
|
|
3038
|
+
// client details are preserved
|
|
3039
|
+
if (this.refreshPromise && this.rejectRefresh) {
|
|
3040
|
+
this.rejectRefresh(new Error('Logout during token refresh.'));
|
|
3041
|
+
this.clearRefreshPromise();
|
|
3177
3042
|
}
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
.
|
|
3182
|
-
.
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
jwk: jwk_public_key,
|
|
3187
|
-
})
|
|
3188
|
-
.sign(this.sessionInformation.tokenDetails.dpop_key_pair.privateKey);
|
|
3043
|
+
// clean session database
|
|
3044
|
+
if (this.database) {
|
|
3045
|
+
await this.database.init();
|
|
3046
|
+
await this.database.clear();
|
|
3047
|
+
this.database.close();
|
|
3048
|
+
}
|
|
3049
|
+
// callback state change
|
|
3050
|
+
this.onSessionStateChange?.(); // we logged out
|
|
3189
3051
|
}
|
|
3190
3052
|
/**
|
|
3191
3053
|
* Makes an HTTP fetch request.
|
|
@@ -3197,10 +3059,18 @@ class Session {
|
|
|
3197
3059
|
* @returns A promise that resolves to the fetch Response.
|
|
3198
3060
|
*/
|
|
3199
3061
|
async authFetch(input, init, dpopPayload) {
|
|
3062
|
+
// if there is not session established, just delegate to the default fetch
|
|
3063
|
+
if (!this.isActive) {
|
|
3064
|
+
return fetch(input, init);
|
|
3065
|
+
}
|
|
3066
|
+
// TODO
|
|
3067
|
+
// TODO do HEAD request to check if authentication is actually required, only then include tokens
|
|
3068
|
+
// TODO
|
|
3200
3069
|
// prepare authenticated call using a DPoP token (either provided payload, or default)
|
|
3201
3070
|
let url;
|
|
3202
3071
|
let method;
|
|
3203
3072
|
let headers;
|
|
3073
|
+
// wrangle fetch input parameters into place
|
|
3204
3074
|
if (input instanceof Request) {
|
|
3205
3075
|
url = new URL(input.url);
|
|
3206
3076
|
method = init?.method || input?.method || 'GET';
|
|
@@ -3213,15 +3083,15 @@ class Session {
|
|
|
3213
3083
|
headers = init.headers ? new Headers(init.headers) : new Headers();
|
|
3214
3084
|
}
|
|
3215
3085
|
// create DPoP token, and add tokens to request
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
}
|
|
3086
|
+
await this._renewTokensIfExpired();
|
|
3087
|
+
dpopPayload = dpopPayload ?? {
|
|
3088
|
+
htu: `${url.origin}${url.pathname}`,
|
|
3089
|
+
htm: method.toUpperCase()
|
|
3090
|
+
};
|
|
3091
|
+
const dpop = await this._createSignedDPoPToken(dpopPayload);
|
|
3092
|
+
// overwrite headers: authorization, dpop
|
|
3093
|
+
headers.set("dpop", dpop);
|
|
3094
|
+
headers.set("authorization", `DPoP ${this.information.tokenDetails.access_token}`);
|
|
3225
3095
|
// check explicitly; to avoid unexpected behaviour
|
|
3226
3096
|
if (input instanceof Request) { // clone the provided request, and override the headers
|
|
3227
3097
|
return fetch(new Request(input, { ...init, headers }));
|
|
@@ -3229,64 +3099,59 @@ class Session {
|
|
|
3229
3099
|
// just override the headers
|
|
3230
3100
|
return fetch(url, { ...init, headers });
|
|
3231
3101
|
}
|
|
3102
|
+
//
|
|
3103
|
+
// Setters
|
|
3104
|
+
//
|
|
3105
|
+
async setTokenDetails(tokenDetails) {
|
|
3106
|
+
this.information.tokenDetails = tokenDetails;
|
|
3107
|
+
await this._updateSessionDetailsFromToken(tokenDetails.access_token);
|
|
3108
|
+
}
|
|
3109
|
+
clearRefreshPromise() {
|
|
3110
|
+
this.refreshPromise = undefined;
|
|
3111
|
+
this.resolveRefresh = undefined;
|
|
3112
|
+
this.rejectRefresh = undefined;
|
|
3113
|
+
}
|
|
3114
|
+
//
|
|
3115
|
+
// Getters
|
|
3116
|
+
//
|
|
3232
3117
|
get isActive() {
|
|
3233
3118
|
return this.isActive_;
|
|
3234
3119
|
}
|
|
3235
3120
|
get webId() {
|
|
3236
3121
|
return this.webId_;
|
|
3237
3122
|
}
|
|
3123
|
+
getExpiresIn() {
|
|
3124
|
+
return this.information.tokenDetails?.expires_in ?? -1;
|
|
3125
|
+
}
|
|
3126
|
+
isExpired() {
|
|
3127
|
+
if (!this.exp_)
|
|
3128
|
+
return true;
|
|
3129
|
+
return this._isTokenExpired(this.exp_);
|
|
3130
|
+
}
|
|
3131
|
+
getTokenDetails() {
|
|
3132
|
+
return this.information.tokenDetails;
|
|
3133
|
+
}
|
|
3238
3134
|
//
|
|
3239
|
-
//
|
|
3135
|
+
// Helpers
|
|
3240
3136
|
//
|
|
3241
3137
|
/**
|
|
3242
|
-
*
|
|
3138
|
+
* Check if the current token is expired (which may happen during device/browser/tab hibernation),
|
|
3139
|
+
* and if expired, restore the session.
|
|
3243
3140
|
*/
|
|
3244
|
-
async
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
// check for active session
|
|
3252
|
-
this.webId_ = decodeJwt(this.sessionInformation.tokenDetails.access_token)["webid"];
|
|
3253
|
-
this.isActive_ = this.webId !== undefined;
|
|
3254
|
-
// deactivating session when token expire
|
|
3255
|
-
this.setSessionDeactivateTimeout();
|
|
3256
|
-
// refreshing tokens
|
|
3257
|
-
this.setTokenRefreshTimeout();
|
|
3258
|
-
}
|
|
3259
|
-
setSessionDeactivateTimeout() {
|
|
3260
|
-
const deactivate_buffer_seconds = 5;
|
|
3261
|
-
const timeUntilDeactivate = (this.sessionInformation.tokenDetails.expires_in - deactivate_buffer_seconds) * 1000;
|
|
3262
|
-
if (this.sessionDeactivateTimeout)
|
|
3263
|
-
clearTimeout(this.sessionDeactivateTimeout);
|
|
3264
|
-
this.sessionDeactivateTimeout = setTimeout(() => this.logout(), timeUntilDeactivate);
|
|
3265
|
-
}
|
|
3266
|
-
setTokenRefreshTimeout() {
|
|
3267
|
-
const refresh_buffer_seconds = 95;
|
|
3268
|
-
const timeUntilRefresh = (this.sessionInformation.tokenDetails.expires_in - refresh_buffer_seconds) * 1000;
|
|
3269
|
-
if (this.tokenRefreshTimeout)
|
|
3270
|
-
clearTimeout(this.tokenRefreshTimeout);
|
|
3271
|
-
this.tokenRefreshTimeout = setTimeout(async () => {
|
|
3272
|
-
const newTokens = await renewTokens()
|
|
3273
|
-
.catch((error) => {
|
|
3274
|
-
// anything missing or wrong => could not renew tokens.
|
|
3275
|
-
if (this.onSessionExpirationWarning)
|
|
3276
|
-
this.onSessionExpirationWarning();
|
|
3277
|
-
return undefined;
|
|
3278
|
-
});
|
|
3279
|
-
if (!newTokens) {
|
|
3280
|
-
return;
|
|
3141
|
+
async _renewTokensIfExpired() {
|
|
3142
|
+
if (this.isExpired()) {
|
|
3143
|
+
if (!this.refreshPromise) {
|
|
3144
|
+
await this.restore(); // Initiate and wait
|
|
3145
|
+
}
|
|
3146
|
+
else {
|
|
3147
|
+
await this.refreshPromise; // Wait for already pending
|
|
3281
3148
|
}
|
|
3282
|
-
|
|
3283
|
-
this.setSessionDetails();
|
|
3284
|
-
}, timeUntilRefresh);
|
|
3149
|
+
}
|
|
3285
3150
|
}
|
|
3286
3151
|
/**
|
|
3287
3152
|
* RFC 9449 - Hash of the access token
|
|
3288
3153
|
*/
|
|
3289
|
-
async
|
|
3154
|
+
async _computeAth(accessToken) {
|
|
3290
3155
|
// Convert the ASCII string of the token to a Uint8Array
|
|
3291
3156
|
const encoder = new TextEncoder();
|
|
3292
3157
|
const data = encoder.encode(accessToken); // ASCII by default
|
|
@@ -3302,6 +3167,470 @@ class Session {
|
|
|
3302
3167
|
.replace(/=+$/, '');
|
|
3303
3168
|
return base64url;
|
|
3304
3169
|
}
|
|
3170
|
+
/**
|
|
3171
|
+
* Creates a signed DPoP (Demonstration of Proof-of-Possession) token.
|
|
3172
|
+
*
|
|
3173
|
+
* @param payload The payload to include in the DPoP token. By default, it includes `htu` (HTTP target URI) and `htm` (HTTP method).
|
|
3174
|
+
* @returns A promise that resolves to the signed DPoP token string.
|
|
3175
|
+
* @throws Error if the session has not been initialized - if no token details are available.
|
|
3176
|
+
*/
|
|
3177
|
+
async _createSignedDPoPToken(payload) {
|
|
3178
|
+
if (!this.information.tokenDetails || !this.currentAth_) {
|
|
3179
|
+
throw new Error("Session not established.");
|
|
3180
|
+
}
|
|
3181
|
+
payload.ath = this.currentAth_;
|
|
3182
|
+
const jwk_public_key = await exportJWK(this.information.tokenDetails.dpop_key_pair.publicKey);
|
|
3183
|
+
return new SignJWT(payload)
|
|
3184
|
+
.setIssuedAt()
|
|
3185
|
+
.setJti(window.crypto.randomUUID())
|
|
3186
|
+
.setProtectedHeader({
|
|
3187
|
+
alg: "ES256",
|
|
3188
|
+
typ: "dpop+jwt",
|
|
3189
|
+
jwk: jwk_public_key,
|
|
3190
|
+
})
|
|
3191
|
+
.sign(this.information.tokenDetails.dpop_key_pair.privateKey);
|
|
3192
|
+
}
|
|
3193
|
+
async _updateSessionDetailsFromToken(access_token) {
|
|
3194
|
+
if (!access_token) {
|
|
3195
|
+
await this.logout();
|
|
3196
|
+
return;
|
|
3197
|
+
}
|
|
3198
|
+
try {
|
|
3199
|
+
const decodedToken = decodeJwt(access_token);
|
|
3200
|
+
const webId = decodedToken.webid;
|
|
3201
|
+
if (!webId) {
|
|
3202
|
+
throw new Error('Missing webid claim in access token');
|
|
3203
|
+
}
|
|
3204
|
+
const exp = decodedToken.exp;
|
|
3205
|
+
if (!exp) {
|
|
3206
|
+
throw new Error('Missing exp claim in access token');
|
|
3207
|
+
}
|
|
3208
|
+
this.currentAth_ = await this._computeAth(access_token); // must be done before session set to active
|
|
3209
|
+
this.webId_ = webId;
|
|
3210
|
+
this.exp_ = exp;
|
|
3211
|
+
this.isActive_ = true;
|
|
3212
|
+
}
|
|
3213
|
+
catch (error) {
|
|
3214
|
+
await this.logout();
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
/**
|
|
3218
|
+
* Checks if a JWT expiration timestamp ('exp') has passed.
|
|
3219
|
+
*/
|
|
3220
|
+
_isTokenExpired(exp, bufferSeconds = 0) {
|
|
3221
|
+
if (typeof exp !== 'number' || isNaN(exp)) {
|
|
3222
|
+
return true;
|
|
3223
|
+
}
|
|
3224
|
+
const currentTimeSeconds = Math.floor(Date.now() / 1000);
|
|
3225
|
+
return exp < (currentTimeSeconds + bufferSeconds);
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
// extracting this from Session.ts such that the jest tests would compile :)
|
|
3230
|
+
const getWorkerUrl = () => new URL('./RefreshWorker.js', import.meta.url);
|
|
3231
|
+
|
|
3232
|
+
/**
|
|
3233
|
+
* A simple IndexedDB wrapper.
|
|
3234
|
+
*/
|
|
3235
|
+
class SessionIDB {
|
|
3236
|
+
dbName;
|
|
3237
|
+
storeName;
|
|
3238
|
+
dbVersion;
|
|
3239
|
+
db = null;
|
|
3240
|
+
/**
|
|
3241
|
+
* Creates a new instance
|
|
3242
|
+
* @param dbName The name of the IndexedDB database
|
|
3243
|
+
* @param storeName The name of the object store
|
|
3244
|
+
* @param dbVersion The database version
|
|
3245
|
+
*/
|
|
3246
|
+
constructor(dbName = 'soidc', storeName = 'session', dbVersion = 1) {
|
|
3247
|
+
this.dbName = dbName;
|
|
3248
|
+
this.storeName = storeName;
|
|
3249
|
+
this.dbVersion = dbVersion;
|
|
3250
|
+
}
|
|
3251
|
+
/**
|
|
3252
|
+
* Initializes the IndexedDB database
|
|
3253
|
+
* @returns Promise that resolves when the database is ready
|
|
3254
|
+
*/
|
|
3255
|
+
async init() {
|
|
3256
|
+
return new Promise((resolve, reject) => {
|
|
3257
|
+
const request = indexedDB.open(this.dbName, this.dbVersion);
|
|
3258
|
+
request.onerror = (event) => {
|
|
3259
|
+
reject(new Error(`Database error: ${event.target.error}`));
|
|
3260
|
+
};
|
|
3261
|
+
request.onsuccess = (event) => {
|
|
3262
|
+
this.db = event.target.result;
|
|
3263
|
+
resolve(this);
|
|
3264
|
+
};
|
|
3265
|
+
request.onupgradeneeded = (event) => {
|
|
3266
|
+
const db = event.target.result;
|
|
3267
|
+
// Check if the object store already exists, if not create it
|
|
3268
|
+
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
3269
|
+
db.createObjectStore(this.storeName);
|
|
3270
|
+
}
|
|
3271
|
+
};
|
|
3272
|
+
});
|
|
3273
|
+
}
|
|
3274
|
+
/**
|
|
3275
|
+
* Stores any value in the database with the given ID as key
|
|
3276
|
+
* @param id The identifier/key for the value
|
|
3277
|
+
* @param value The value to store
|
|
3278
|
+
*/
|
|
3279
|
+
async setItem(id, value) {
|
|
3280
|
+
if (!this.db) {
|
|
3281
|
+
await this.init();
|
|
3282
|
+
}
|
|
3283
|
+
return new Promise((resolve, reject) => {
|
|
3284
|
+
const transaction = this.db.transaction(this.storeName, 'readwrite');
|
|
3285
|
+
// Handle transation
|
|
3286
|
+
transaction.oncomplete = () => {
|
|
3287
|
+
resolve();
|
|
3288
|
+
};
|
|
3289
|
+
transaction.onerror = (event) => {
|
|
3290
|
+
reject(new Error(`Transaction error for setItem(${id},...): ${event.target.error}`));
|
|
3291
|
+
};
|
|
3292
|
+
transaction.onabort = (event) => {
|
|
3293
|
+
reject(new Error(`Transaction aborted for setItem(${id},...): ${event.target.error}`));
|
|
3294
|
+
};
|
|
3295
|
+
// Perform the request within the transaction
|
|
3296
|
+
const store = transaction.objectStore(this.storeName);
|
|
3297
|
+
store.put(value, id);
|
|
3298
|
+
});
|
|
3299
|
+
}
|
|
3300
|
+
/**
|
|
3301
|
+
* Retrieves a value from the database by ID
|
|
3302
|
+
* @param id The identifier/key for the value
|
|
3303
|
+
* @returns The stored value or null if not found
|
|
3304
|
+
*/
|
|
3305
|
+
async getItem(id) {
|
|
3306
|
+
if (!this.db) {
|
|
3307
|
+
await this.init();
|
|
3308
|
+
}
|
|
3309
|
+
return new Promise((resolve, reject) => {
|
|
3310
|
+
const transaction = this.db.transaction(this.storeName, 'readonly');
|
|
3311
|
+
// Handle transation
|
|
3312
|
+
transaction.onerror = (event) => {
|
|
3313
|
+
reject(new Error(`Transaction error for getItem(${id}): ${event.target.error}`));
|
|
3314
|
+
};
|
|
3315
|
+
transaction.onabort = (event) => {
|
|
3316
|
+
reject(new Error(`Transaction aborted for getItem(${id}): ${event.target.error}`));
|
|
3317
|
+
};
|
|
3318
|
+
// Perform the request within the transaction
|
|
3319
|
+
const store = transaction.objectStore(this.storeName);
|
|
3320
|
+
const request = store.get(id);
|
|
3321
|
+
request.onsuccess = () => {
|
|
3322
|
+
resolve(request.result || null);
|
|
3323
|
+
};
|
|
3324
|
+
});
|
|
3325
|
+
}
|
|
3326
|
+
/**
|
|
3327
|
+
* Removes an item from the database
|
|
3328
|
+
* @param id The identifier of the item to remove
|
|
3329
|
+
*/
|
|
3330
|
+
async deleteItem(id) {
|
|
3331
|
+
if (!this.db) {
|
|
3332
|
+
await this.init();
|
|
3333
|
+
}
|
|
3334
|
+
return new Promise((resolve, reject) => {
|
|
3335
|
+
const transaction = this.db.transaction(this.storeName, 'readwrite');
|
|
3336
|
+
// Handle transation
|
|
3337
|
+
transaction.oncomplete = () => {
|
|
3338
|
+
resolve();
|
|
3339
|
+
};
|
|
3340
|
+
transaction.onerror = (event) => {
|
|
3341
|
+
reject(new Error(`Transaction error for deleteItem(${id}): ${event.target.error}`));
|
|
3342
|
+
};
|
|
3343
|
+
transaction.onabort = (event) => {
|
|
3344
|
+
reject(new Error(`Transaction aborted for deleteItem(${id}): ${event.target.error}`));
|
|
3345
|
+
};
|
|
3346
|
+
// Perform the request within the transaction
|
|
3347
|
+
const store = transaction.objectStore(this.storeName);
|
|
3348
|
+
store.delete(id);
|
|
3349
|
+
});
|
|
3350
|
+
}
|
|
3351
|
+
/**
|
|
3352
|
+
* Clears all items from the database
|
|
3353
|
+
*/
|
|
3354
|
+
async clear() {
|
|
3355
|
+
if (!this.db) {
|
|
3356
|
+
await this.init();
|
|
3357
|
+
}
|
|
3358
|
+
return new Promise((resolve, reject) => {
|
|
3359
|
+
const transaction = this.db.transaction(this.storeName, 'readwrite');
|
|
3360
|
+
// Handle transation
|
|
3361
|
+
transaction.oncomplete = () => {
|
|
3362
|
+
resolve();
|
|
3363
|
+
};
|
|
3364
|
+
transaction.onerror = (event) => {
|
|
3365
|
+
reject(new Error(`Transaction error for clear(): ${event.target.error}`));
|
|
3366
|
+
};
|
|
3367
|
+
transaction.onabort = (event) => {
|
|
3368
|
+
reject(new Error(`Transaction aborted for clear(): ${event.target.error}`));
|
|
3369
|
+
};
|
|
3370
|
+
// Perform the request within the transaction
|
|
3371
|
+
const store = transaction.objectStore(this.storeName);
|
|
3372
|
+
store.clear();
|
|
3373
|
+
});
|
|
3374
|
+
}
|
|
3375
|
+
/**
|
|
3376
|
+
* Closes the database connection
|
|
3377
|
+
*/
|
|
3378
|
+
close() {
|
|
3379
|
+
if (this.db) {
|
|
3380
|
+
this.db.close();
|
|
3381
|
+
this.db = null;
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
var RefreshMessageTypes;
|
|
3387
|
+
(function (RefreshMessageTypes) {
|
|
3388
|
+
RefreshMessageTypes["SCHEDULE"] = "SCHEDULE";
|
|
3389
|
+
RefreshMessageTypes["REFRESH"] = "REFRESH";
|
|
3390
|
+
RefreshMessageTypes["STOP"] = "STOP";
|
|
3391
|
+
RefreshMessageTypes["DISCONNECT"] = "DISCONNECT";
|
|
3392
|
+
RefreshMessageTypes["TOKEN_DETAILS"] = "TOKEN_DETAILS";
|
|
3393
|
+
RefreshMessageTypes["ERROR_ON_REFRESH"] = "ERROR_ON_REFRESH";
|
|
3394
|
+
RefreshMessageTypes["EXPIRED"] = "EXPIRED";
|
|
3395
|
+
})(RefreshMessageTypes || (RefreshMessageTypes = {}));
|
|
3396
|
+
// A Set to store all connected ports (tabs)
|
|
3397
|
+
const ports = new Set();
|
|
3398
|
+
const broadcast = (message) => {
|
|
3399
|
+
for (const p of ports) {
|
|
3400
|
+
p.postMessage(message);
|
|
3401
|
+
}
|
|
3402
|
+
};
|
|
3403
|
+
let refresher;
|
|
3404
|
+
self.onconnect = (event) => {
|
|
3405
|
+
const port = event.ports[0];
|
|
3406
|
+
ports.add(port);
|
|
3407
|
+
// lazy init
|
|
3408
|
+
if (!refresher) {
|
|
3409
|
+
refresher = new Refresher(broadcast, new SessionIDB());
|
|
3410
|
+
}
|
|
3411
|
+
// handle messages
|
|
3412
|
+
port.onmessage = (event) => {
|
|
3413
|
+
const { type, payload } = event.data;
|
|
3414
|
+
switch (type) {
|
|
3415
|
+
case RefreshMessageTypes.SCHEDULE:
|
|
3416
|
+
refresher.handleSchedule(payload);
|
|
3417
|
+
break;
|
|
3418
|
+
case RefreshMessageTypes.REFRESH:
|
|
3419
|
+
refresher.handleRefresh(port);
|
|
3420
|
+
break;
|
|
3421
|
+
case RefreshMessageTypes.STOP:
|
|
3422
|
+
refresher.handleStop();
|
|
3423
|
+
break;
|
|
3424
|
+
case RefreshMessageTypes.DISCONNECT:
|
|
3425
|
+
ports.delete(port);
|
|
3426
|
+
break;
|
|
3427
|
+
}
|
|
3428
|
+
};
|
|
3429
|
+
port.onmessageerror = () => ports.delete(port);
|
|
3430
|
+
port.start();
|
|
3431
|
+
};
|
|
3432
|
+
class Refresher {
|
|
3433
|
+
tokenDetails;
|
|
3434
|
+
exp;
|
|
3435
|
+
refreshTimeout;
|
|
3436
|
+
finalLogoutTimeout;
|
|
3437
|
+
timersAreRunning = false;
|
|
3438
|
+
broadcast;
|
|
3439
|
+
database;
|
|
3440
|
+
refreshPromise;
|
|
3441
|
+
constructor(broadcast, database) {
|
|
3442
|
+
this.broadcast = broadcast;
|
|
3443
|
+
this.database = database;
|
|
3444
|
+
}
|
|
3445
|
+
async handleSchedule(tokenDetails) {
|
|
3446
|
+
this.tokenDetails = tokenDetails;
|
|
3447
|
+
this.exp = decodeJwt(this.tokenDetails.access_token).exp;
|
|
3448
|
+
this.broadcast({
|
|
3449
|
+
type: RefreshMessageTypes.TOKEN_DETAILS,
|
|
3450
|
+
payload: { tokenDetails: this.tokenDetails }
|
|
3451
|
+
});
|
|
3452
|
+
console.log(`[RefreshWorker] Scheduling timers, expiry in ${this.tokenDetails.expires_in}s`);
|
|
3453
|
+
this.scheduleTimers(this.tokenDetails.expires_in);
|
|
3454
|
+
this.timersAreRunning = true;
|
|
3455
|
+
}
|
|
3456
|
+
async handleRefresh(requestingPort) {
|
|
3457
|
+
if (this.tokenDetails && this.exp && !this.isTokenExpired(this.exp)) {
|
|
3458
|
+
console.log(`[RefreshWorker] Providing current tokens`);
|
|
3459
|
+
requestingPort.postMessage({
|
|
3460
|
+
type: RefreshMessageTypes.TOKEN_DETAILS,
|
|
3461
|
+
payload: { tokenDetails: this.tokenDetails }
|
|
3462
|
+
});
|
|
3463
|
+
}
|
|
3464
|
+
else {
|
|
3465
|
+
console.log(`[RefreshWorker] Refreshing tokens`);
|
|
3466
|
+
this.performRefresh();
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
handleStop() {
|
|
3470
|
+
if (!this.tokenDetails) {
|
|
3471
|
+
console.log('[RefreshWorker] Received STOP, being idle');
|
|
3472
|
+
return;
|
|
3473
|
+
}
|
|
3474
|
+
this.broadcast({ type: RefreshMessageTypes.EXPIRED });
|
|
3475
|
+
this.tokenDetails = undefined;
|
|
3476
|
+
this.exp = undefined;
|
|
3477
|
+
this.refreshPromise = undefined;
|
|
3478
|
+
console.log('[RefreshWorker] Received STOP, clearing timers');
|
|
3479
|
+
this.clearAllTimers();
|
|
3480
|
+
}
|
|
3481
|
+
async performRefresh() {
|
|
3482
|
+
if (this.refreshPromise) {
|
|
3483
|
+
console.log('[RefreshWorker] Refresh already in progress, waiting...');
|
|
3484
|
+
return this.refreshPromise;
|
|
3485
|
+
}
|
|
3486
|
+
this.refreshPromise = this.doRefresh();
|
|
3487
|
+
return this.refreshPromise;
|
|
3488
|
+
}
|
|
3489
|
+
async doRefresh() {
|
|
3490
|
+
try {
|
|
3491
|
+
this.tokenDetails = await renewTokens(this.database);
|
|
3492
|
+
this.exp = decodeJwt(this.tokenDetails.access_token).exp;
|
|
3493
|
+
this.broadcast({
|
|
3494
|
+
type: RefreshMessageTypes.TOKEN_DETAILS,
|
|
3495
|
+
payload: { tokenDetails: this.tokenDetails }
|
|
3496
|
+
});
|
|
3497
|
+
console.log(`[RefreshWorker] Token refreshed`);
|
|
3498
|
+
console.log(`[RefreshWorker] Scheduling timers, expiry in ${this.tokenDetails.expires_in}s`);
|
|
3499
|
+
this.scheduleTimers(this.tokenDetails.expires_in);
|
|
3500
|
+
}
|
|
3501
|
+
catch (error) {
|
|
3502
|
+
this.broadcast({
|
|
3503
|
+
type: RefreshMessageTypes.ERROR_ON_REFRESH,
|
|
3504
|
+
error: error.message
|
|
3505
|
+
});
|
|
3506
|
+
console.log(`[RefreshWorker]`, error.message);
|
|
3507
|
+
}
|
|
3508
|
+
finally {
|
|
3509
|
+
this.refreshPromise = undefined;
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
clearAllTimers() {
|
|
3513
|
+
if (this.refreshTimeout)
|
|
3514
|
+
clearTimeout(this.refreshTimeout);
|
|
3515
|
+
if (this.finalLogoutTimeout)
|
|
3516
|
+
clearTimeout(this.finalLogoutTimeout);
|
|
3517
|
+
this.timersAreRunning = false;
|
|
3518
|
+
}
|
|
3519
|
+
scheduleTimers(expiresIn) {
|
|
3520
|
+
this.clearAllTimers();
|
|
3521
|
+
this.timersAreRunning = true;
|
|
3522
|
+
const expiresInMs = expiresIn * 1000;
|
|
3523
|
+
const REFRESH_THRESHOLD_RATIO = 0.8;
|
|
3524
|
+
const MINIMUM_REFRESH_BUFFER_MS = 30 * 1000;
|
|
3525
|
+
const timeUntilRefresh = REFRESH_THRESHOLD_RATIO * expiresInMs;
|
|
3526
|
+
if (timeUntilRefresh > MINIMUM_REFRESH_BUFFER_MS) {
|
|
3527
|
+
this.refreshTimeout = setTimeout(() => this.performRefresh(), timeUntilRefresh);
|
|
3528
|
+
}
|
|
3529
|
+
const LOGOUT_WARNING_BUFFER_MS = 5 * 1000;
|
|
3530
|
+
const timeUntilLogout = expiresInMs - LOGOUT_WARNING_BUFFER_MS;
|
|
3531
|
+
this.finalLogoutTimeout = setTimeout(() => {
|
|
3532
|
+
this.tokenDetails = undefined;
|
|
3533
|
+
this.broadcast({ type: RefreshMessageTypes.EXPIRED });
|
|
3534
|
+
}, timeUntilLogout);
|
|
3535
|
+
}
|
|
3536
|
+
isTokenExpired(exp, bufferSeconds = 0) {
|
|
3537
|
+
if (typeof exp !== 'number' || isNaN(exp)) {
|
|
3538
|
+
return true;
|
|
3539
|
+
}
|
|
3540
|
+
const currentTimeSeconds = Math.floor(Date.now() / 1000);
|
|
3541
|
+
return exp < (currentTimeSeconds + bufferSeconds);
|
|
3542
|
+
}
|
|
3543
|
+
setTokenDetails(tokenDetails) {
|
|
3544
|
+
this.tokenDetails = tokenDetails;
|
|
3545
|
+
}
|
|
3546
|
+
// For testing
|
|
3547
|
+
getTimersAreRunning() { return this.timersAreRunning; }
|
|
3548
|
+
getTokenDetails() { return this.tokenDetails; }
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
/**
|
|
3552
|
+
* This Session provides background token refreshing using a Web Worker.
|
|
3553
|
+
*/
|
|
3554
|
+
class WebWorkerSession extends SessionCore {
|
|
3555
|
+
worker;
|
|
3556
|
+
onSessionExpirationWarning;
|
|
3557
|
+
onSessionExpiration;
|
|
3558
|
+
constructor(clientDetails, sessionOptions) {
|
|
3559
|
+
const database = new SessionIDB();
|
|
3560
|
+
const options = { ...sessionOptions, database };
|
|
3561
|
+
super(clientDetails, options);
|
|
3562
|
+
this.onSessionExpirationWarning = sessionOptions?.onSessionExpirationWarning;
|
|
3563
|
+
this.onSessionExpiration = sessionOptions?.onSessionExpiration;
|
|
3564
|
+
// Allow consumer to provide worker URL, or use default
|
|
3565
|
+
const workerUrl = sessionOptions?.workerUrl ?? getWorkerUrl();
|
|
3566
|
+
this.worker = new SharedWorker(workerUrl, { type: 'module' });
|
|
3567
|
+
this.worker.port.onmessage = (event) => {
|
|
3568
|
+
this.handleWorkerMessage(event.data).catch(console.error);
|
|
3569
|
+
};
|
|
3570
|
+
window.addEventListener('beforeunload', () => {
|
|
3571
|
+
this.worker.port.postMessage({ type: RefreshMessageTypes.DISCONNECT });
|
|
3572
|
+
});
|
|
3573
|
+
}
|
|
3574
|
+
async handleWorkerMessage(data) {
|
|
3575
|
+
const { type, payload, error } = data;
|
|
3576
|
+
switch (type) {
|
|
3577
|
+
case RefreshMessageTypes.TOKEN_DETAILS:
|
|
3578
|
+
const wasActive = this.isActive;
|
|
3579
|
+
await this.setTokenDetails(payload.tokenDetails);
|
|
3580
|
+
if (wasActive !== this.isActive)
|
|
3581
|
+
this.onSessionStateChange?.();
|
|
3582
|
+
if (this.refreshPromise && this.resolveRefresh) {
|
|
3583
|
+
this.resolveRefresh();
|
|
3584
|
+
this.clearRefreshPromise();
|
|
3585
|
+
}
|
|
3586
|
+
break;
|
|
3587
|
+
case RefreshMessageTypes.ERROR_ON_REFRESH:
|
|
3588
|
+
if (this.isActive)
|
|
3589
|
+
this.onSessionExpirationWarning?.();
|
|
3590
|
+
if (this.refreshPromise && this.rejectRefresh) {
|
|
3591
|
+
if (this.isActive) {
|
|
3592
|
+
this.rejectRefresh(new Error(error || 'Token refresh failed'));
|
|
3593
|
+
}
|
|
3594
|
+
else {
|
|
3595
|
+
this.rejectRefresh(new Error("No session to restore"));
|
|
3596
|
+
}
|
|
3597
|
+
this.clearRefreshPromise();
|
|
3598
|
+
}
|
|
3599
|
+
break;
|
|
3600
|
+
case RefreshMessageTypes.EXPIRED:
|
|
3601
|
+
if (this.isActive) {
|
|
3602
|
+
this.onSessionExpiration?.();
|
|
3603
|
+
await this.logout();
|
|
3604
|
+
}
|
|
3605
|
+
if (this.refreshPromise && this.rejectRefresh) {
|
|
3606
|
+
this.rejectRefresh(new Error(error || 'Token refresh failed'));
|
|
3607
|
+
this.clearRefreshPromise();
|
|
3608
|
+
}
|
|
3609
|
+
break;
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
;
|
|
3613
|
+
async handleRedirectFromLogin() {
|
|
3614
|
+
await super.handleRedirectFromLogin();
|
|
3615
|
+
if (this.isActive) { // If login was successful, tell the worker to schedule refreshing
|
|
3616
|
+
this.worker.port.postMessage({ type: RefreshMessageTypes.SCHEDULE, payload: this.getTokenDetails() });
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
async restore() {
|
|
3620
|
+
if (this.refreshPromise) {
|
|
3621
|
+
return this.refreshPromise;
|
|
3622
|
+
}
|
|
3623
|
+
this.refreshPromise = new Promise((resolve, reject) => {
|
|
3624
|
+
this.resolveRefresh = resolve;
|
|
3625
|
+
this.rejectRefresh = reject;
|
|
3626
|
+
});
|
|
3627
|
+
this.worker.port.postMessage({ type: RefreshMessageTypes.REFRESH });
|
|
3628
|
+
return this.refreshPromise;
|
|
3629
|
+
}
|
|
3630
|
+
async logout() {
|
|
3631
|
+
this.worker.port.postMessage({ type: RefreshMessageTypes.STOP });
|
|
3632
|
+
await super.logout();
|
|
3633
|
+
}
|
|
3305
3634
|
}
|
|
3306
3635
|
|
|
3307
3636
|
class BrowserSession {
|
|
@@ -3314,7 +3643,7 @@ class BrowserSession {
|
|
|
3314
3643
|
isLoggedIn: false,
|
|
3315
3644
|
webId: undefined,
|
|
3316
3645
|
});
|
|
3317
|
-
this.session = new
|
|
3646
|
+
this.session = new WebWorkerSession();
|
|
3318
3647
|
this._authenticatedFetch = this.session.authFetch.bind(this.session);
|
|
3319
3648
|
}
|
|
3320
3649
|
async handleIncomingRedirect(restorePreviousSession = false) {
|