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