@pod-os/elements 0.32.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.
@@ -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 != idp - " + issuer + " != " + idp);
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
- // remember client_id
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") != idp) {
2861
- throw new Error("RFC 9207 - iss != idp - " + url.searchParams.get("iss") + " != " + idp);
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") != sessionStorage.getItem("csrf_token")) {
2865
- throw new Error("RFC 6749 - state != csrf_token - " + url.searchParams.get("state") + " != " + sessionStorage.getItem("csrf_token"));
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"] != dpopThumbprint) {
2910
- throw new Error("Access Token validation failed on `jkt`: jkt != DPoP thumbprint - " + payload["cnf"]["jkt"] + " != " + dpopThumbprint);
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"] != client_id) {
2914
- throw new Error("Access Token validation failed on `client_id`: JWT payload != client_id - " + payload["client_id"] + " != " + client_id);
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: [window.location.href] };
2771
+ client_details = { redirect_uris: [url.toString()] };
2921
2772
  client_details.client_id = client_id;
2922
- // to remember for session restore
2923
- const sessionDatabase = await new SessionDatabase().init();
2924
- await Promise.all([
2925
- sessionDatabase.setItem("idp", idp),
2926
- sessionDatabase.setItem("jwks_uri", jwks_uri),
2927
- sessionDatabase.setItem("token_endpoint", token_endpoint),
2928
- sessionDatabase.setItem("client_id", client_id),
2929
- sessionDatabase.setItem("dpop_keypair", key_pair),
2930
- sessionDatabase.setItem("refresh_token", token_response["refresh_token"])
2931
- ]);
2932
- sessionDatabase.close();
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
- const sessionDatabase = await new SessionDatabase().init();
2993
- const client_id = await sessionDatabase.getItem("client_id");
2994
- const token_endpoint = await sessionDatabase.getItem("token_endpoint");
2995
- const key_pair = await sessionDatabase.getItem("dpop_keypair");
2996
- const refresh_token = await sessionDatabase.getItem("refresh_token");
2997
- if (client_id === null || token_endpoint === null || key_pair === null || refresh_token === null) {
2998
- // we can not restore the old session
2999
- throw new Error("Could not refresh tokens: details missing from database.");
3000
- }
3001
- const token_response = await requestFreshTokens(refresh_token, client_id, token_endpoint, key_pair)
3002
- .then((response) => {
3003
- if (!response.ok) {
3004
- throw new Error(`HTTP error! Status: ${response.status}`);
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
- return response.json();
3007
- });
3008
- // verify access_token // ! Solid-OIDC specification says it should be a dpop-bound `id token` but implementations provide a dpop-bound `access token`
3009
- const accessToken = token_response["access_token"];
3010
- const idp = await sessionDatabase.getItem("idp");
3011
- if (idp === null) {
3012
- throw new Error("Access Token validation preparation - Could not find in sessionDatabase: idp");
3013
- }
3014
- const jwks_uri = await sessionDatabase.getItem("jwks_uri");
3015
- if (jwks_uri === null) {
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
- // check client_id
3030
- if (payload["client_id"] != client_id) {
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(window.crypto.randomUUID())
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
- class Session {
3083
- sessionInformation;
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
- tokenRefreshTimeout;
3088
- sessionDeactivateTimeout;
3089
- onSessionExpirationWarning;
3090
- /**
3091
- * Create a new session.
3092
- */
2957
+ onSessionStateChange;
2958
+ information;
2959
+ database;
2960
+ refreshPromise;
2961
+ resolveRefresh;
2962
+ rejectRefresh;
3093
2963
  constructor(clientDetails, sessionOptions) {
3094
- this.sessionInformation = { clientDetails };
3095
- this.onSessionExpirationWarning = sessionOptions?.onSessionExpirationWarning;
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.sessionInformation.clientDetails);
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.sessionInformation.clientDetails);
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.sessionInformation = newSessionInfo;
3149
- await this.setSessionDetails();
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
- // Restore session using Refresh Token Grant // memory via IndexedDB
3157
- await renewTokens()
3158
- .then(tokenDetails => {
3159
- // got new tokens
3160
- this.sessionInformation.tokenDetails = tokenDetails;
3161
- // set session information
3162
- return this.setSessionDetails();
3163
- })
3164
- // anything missing or wrong => abort, could not restore session.
3165
- .catch(_ => { }); // fail silently
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
- * Creates a signed DPoP (Demonstration of Proof-of-Possession) token.
3169
- *
3170
- * @param payload The payload to include in the DPoP token. By default, it includes `htu` (HTTP target URI) and `htm` (HTTP method).
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 createSignedDPoPToken(payload) {
3175
- if (!this.sessionInformation.tokenDetails || !this.currentAth_) {
3176
- throw new Error("Session not established.");
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
- payload.ath = this.currentAth_;
3179
- const jwk_public_key = await exportJWK(this.sessionInformation.tokenDetails.dpop_key_pair.publicKey);
3180
- return new SignJWT(payload)
3181
- .setIssuedAt()
3182
- .setJti(window.crypto.randomUUID())
3183
- .setProtectedHeader({
3184
- alg: "ES256",
3185
- typ: "dpop+jwt",
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
- if (this.sessionInformation.tokenDetails) {
3217
- dpopPayload = dpopPayload ?? {
3218
- htu: `${url.origin}${url.pathname}`,
3219
- htm: method.toUpperCase()
3220
- };
3221
- const dpop = await this.createSignedDPoPToken(dpopPayload);
3222
- headers.set("dpop", dpop);
3223
- headers.set("authorization", `DPoP ${this.sessionInformation.tokenDetails.access_token}`);
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
- // Helper Methods
3135
+ // Helpers
3240
3136
  //
3241
3137
  /**
3242
- * Set the session to active if there is an access token.
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 setSessionDetails() {
3245
- // check for access token
3246
- if (!this.sessionInformation.tokenDetails?.access_token) {
3247
- this.logout();
3248
- }
3249
- // generate ath
3250
- this.currentAth_ = await this.computeAth(this.sessionInformation.tokenDetails.access_token);
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
- this.sessionInformation.tokenDetails = newTokens;
3283
- this.setSessionDetails();
3284
- }, timeUntilRefresh);
3149
+ }
3285
3150
  }
3286
3151
  /**
3287
3152
  * RFC 9449 - Hash of the access token
3288
3153
  */
3289
- async computeAth(accessToken) {
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 Session();
3646
+ this.session = new WebWorkerSession();
3318
3647
  this._authenticatedFetch = this.session.authFetch.bind(this.session);
3319
3648
  }
3320
3649
  async handleIncomingRedirect(restorePreviousSession = false) {