Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Silent Renew Process - Storage Access APIs - PoC Working in Firefox #1735

Open
deanmaster opened this issue Nov 7, 2024 · 3 comments · May be fixed by #1775
Open

Silent Renew Process - Storage Access APIs - PoC Working in Firefox #1735

deanmaster opened this issue Nov 7, 2024 · 3 comments · May be fixed by #1775

Comments

@deanmaster
Copy link

deanmaster commented Nov 7, 2024

Hello @pamapa ,

With 3rd parties cookie phase out problem happened in Firefox for long time and probably (going) to happen in Chrome early next year I tried to implement an PoC which can resolve the problem. I really need this solution implemented in our library because my PoC include patching javascript code directly therefore I need your support to review and help to deliver the code.

The 3rd party cookie phase out is heavily effect our customers which are using Firefox browser and the issue is really getting serious. it will soon become a show stopper.

Here is my PoC flows and remaining issue:

Environment setup:

  1. Using Keycloak as IDP and deploy in https://local.tuando.com (fake domain using nginx and etc hosts changes).
  2. Using oidc-client-ts within application runs in https://localhost:3000 (using webpack and asking for https run instead of http)
  3. Configure PKCE and access token lifetime for 2 mins in Keycloak.
  4. Configure application to login with Keycloak.
  5. Waiting for 60 second.
  6. The application failed for silent renew process. The IDP return login_required error.

Problems: keycloak_session (keyclock_session_legacy) is required to know the user is already login so that IDP can make renew token.

Firefox behavior:

  • 3rd party cookie access are forbidden by default therefore access keycloak_session is not possible.
  • logout request is trigger or
  • login_required is return from Keycloak
  • make the user logout after 5 mins

Chrome behavior: 3rd party cookie access is still possible therefore the silent renew process can still return new token.

Solution proposal and already proved via PoC:

  • Create an HTML page contains:

    • javascript function to asking for permission access Storage Access API.
    • javascript function to verify if having access to Storage Access with IDP domain.
    • create a button and click function will trigger document.requestStorageAccess()
  • The HTML will be loaded at startup of your application within an IFrame (runs on https://localhost:3000)

  • The HTML must be return from the same domain as your IDP (keycloak is running) which is https://local.tuando.com

  • The idea is to asking for Storage Access for IDP domain before the actual IFrame for silent renew is created.

  • This IFrame will be shown to the end user, asking for permission by require user to click on button to request storage access.

  • Next time, the actual Silent Renew IFrame created (by oidc-client-ts) the IDP domain already have storage access so IDP cookies will be sent together with silent renew URLs.

  • Create another HTML contains:

  • javascript function to asking for permission access Storage Access API. (assume it always work due to the first IFrame)

  • When having access post a message to parent window application (runs in https://localhost:3000 )

  • Application listen to message allow access storage.

  • Application set redirect url via this._window.postMessage({redirectUrl: params.url}, "*");

  • The HTML (runs within IFrame) has message listener and execute window.location.href = event.data.redirectUrl;

Long story short:

  • Make sure there is IFrame request End User to access Storage Access via Button Click (It must be user interaction otherwise Firefox will not trigger permission request)
  • Make sure there is a IFrame place holder for silent renew because if you replace the URL with Silent Renew URL, Firefox will create another context and asking permission again can cause timeout for the IFrame itselves (what is your oppinion @pamapa )

Things need to be done:

  1. oidc-client-ts
  • function createHiddenIFrame has to changed:
static createHiddenIframe() {
    const iframe = window.document.createElement("iframe");
    iframe.style.visibility = "hidden";
    iframe.style.position = "fixed";
    iframe.style.left = "-1000px";
    iframe.style.top = "0";
    iframe.width = "0";
    iframe.height = "0";
    //This make sure the IFrame which load url from IDP allows to request top level storage access and User Activation to enable access 3rd party cookie.
    iframe.sandbox = "allow-storage-access-by-user-activation allow-scripts allow-same-origin";
    //link to IFrame from IDP which is used to request cookie access.
    iframe.src="https://local.tuando.com/realms/UAARealm/uaa/silentRenewPlaceholderIFrame"
    // iframe.id = "silentRenewIframe"
    window.document.body.appendChild(iframe);
    return iframe;
  }
  • In AbstractWindow.ts, adding the message listener
window.addEventListener("message", (event) => {
      // Ensure the message comes from IDP Origin iframe to prepare top level storage access permission.
      if (event.origin === "https://local.tuando.com" && event.data.status === "storageAccessPermissionGranted" && this._window) {
        console.log("Iframe script finished executing to request storage access ", event);
        this._window.postMessage({redirectUrl: params.url}, "*");
        // this._window.location.replace(params.url);
      }
    });
  • The request storage API with button looks like this:
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Request Storage Access UI</title>
		<script type="application/javascript">
			async function requestStorageAccess() {
				console.log("Don't have access. Trying again within this click handler, in case it needed a prompt.");
				try {
					await document.requestStorageAccess();
					window.parent.postMessage({ status: "hasStorageAccessPermissionUI" }, "*");
					console.log("User allowed access storage access. Notified parent window");
				} catch (err) {
					console.warn("requestStorageAccess Error:", err);
				}
				console.log("Has storage access:", await document.hasStorageAccess());
			}

			async function hasCookieAccess() {
				// Check if Storage Access API is supported
				if (!document.requestStorageAccess) {
					// Storage Access API is not supported so best we can do is
					// hope it's an older browser that doesn't block 3P cookies
					console.log("Storage Access API not supported. Assume we have access.")
					return;
				}

				// Check if access has already been granted
				if (await document.hasStorageAccess()) {
					console.log("Cookie access already granted " + document.cookie);
					return;
				}

				// Check the storage-access permission
				try {
					const permission = await navigator.permissions.query({
						name: "storage-access",
					});
					console.log("permissions storage-access: ", permission);

					if (permission.state === "granted") {
						// Can just call requestStorageAccess() without a
						// user interaction and it will resolve automatically.
						try {
							await document.requestStorageAccess();
							console.log("Cookie access granted. Calling requestStorageAccess()");
						} catch (error) {
							console.log("This shouldn't really fail if access is granted");
						}
					} else if (permission.state === "prompt") {
						//If the permission status is "prompt" we need to call
						//document.requestStorageAccess() from within a user gesture, such as a button click.
						console.log("Cookie access requires a prompt. User interaction requires. Post message to application.");
						window.parent.postMessage({ status: "endUserRequestAccessStorage" }, "*");
					} else if (permission.state === "denied") {
						console.log("Cookie access denied. Renew token using IFrame from IDP should failed.");
					}
				} catch (error) {
					console.log("storage-access permission not supported. Assume no access.", error);
				}
			}
			// This function runs as page loads to try to give access initially
			// This can make the click handler quicker as it doesn't need to
			// await the access request if it's already happened.
			async function handleCookieAccessInit() {
				await hasCookieAccess();
			}
			handleCookieAccessInit();
		</script>
</head>
<body>
<div class="content" role="main">
	<button id="requestStorageAccess" onclick="requestStorageAccess()">
        Request Storage Access
		</button>
</div>
</body>
</html>
  • The HTML as Silent Renew place holder looks like this:
<!DOCTYPE html>
<html lang="en">

<head>
  <title>Read Cookie App</title>
	<script type="application/javascript">
		window.addEventListener("message", (event) => {
			if (event.origin === "https://localhost:3000" && event.data.redirectUrl) {
				console.log("listen for redirect to IDP from application most likely in silent renew token process ", event);
				window.location.href = event.data.redirectUrl;
			}
		});

		// let storageAccessPermissionGranted = false;
		async function hasCookieAccess() {
			// Check if Storage Access API is supported
			if (!document.requestStorageAccess) {
				// Storage Access API is not supported so best we can do is
				// hope it's an older browser that doesn't block 3P cookies
				console.log("Storage Acccess API not supported. Assume we have access.")
				// storageAccessPermissionGranted = true;
				window.parent.postMessage({ status: "storageAccessPermissionGranted" }, "*");
				return;
			}

			// Check if access has already been granted
			if (await document.hasStorageAccess()) {
				console.log("Cookie access already granted " + document.cookie);
				// storageAccessPermissionGranted = true;
				window.parent.postMessage({ status: "storageAccessPermissionGranted" }, "*");
				return;
			}
			try {
				const permission = await navigator.permissions.query({
					name: "storage-access",
				});
				console.log("permissions: ", permission);

				if (permission.state === "granted") {
					// Can just call requestStorageAccess() without a
					// user interaction and it will resolve automatically.
					try {
						console.log("Cookie access granted. Calling requestStorageAccess()");
						await document.requestStorageAccess();
						// storageAccessPermissionGranted = true;
						window.parent.postMessage({ status: "storageAccessPermissionGranted" }, "*");
					} catch (error) {
						// storageAccessPermissionGranted = true;
						window.parent.postMessage({ status: "storageAccessPermissionGranted" }, "*");
						console.log("This shouldn't really fail if access is granted.");
					}
				} else if (permission.state === "prompt") {
					//If the permission status is "prompt" we need to call
					//document.requestStorageAccess() from within a user gesture, such as a button click.
					//we will call this from refresh Cookie button
					console.log("Cookie access requires a prompt. When it happens in this time most likely user interaction will not quick enough " +
						" and causes timeout for Silent Renew Timeout IFrame.");
					// storageAccessPermissionGranted = true;
					window.parent.postMessage({ status: "storageAccessPermissionGranted" }, "*");
				} else if (permission.state === "denied") {
					console.log("Cookie access denied");
				}
			} catch (error) {
				// storage-access permission not supported. Assume false.
				console.log("storage-access permission not supported. Assume no access.", error);
			}
		}

		// This function runs as page loads to try to give access initially
		// This can make the click handler quicker as it doesn't need to
		// await the access request if it's already happened.
		async function handleCookieAccessInit() {
			await hasCookieAccess();
		}
		handleCookieAccessInit();

	</script>
</head>
<body>
</body>
</html>
  • Serving these 2 HTML to the same domain as IDP (keycloak)
  • Application code:
  1. Adding IFrame for request Storage Access
<iframe sandbox="allow-storage-access-by-user-activation
                         allow-scripts
                         allow-same-origin" src="https://local.tuando.com/realms/UAARealm/uaa/requestStorageAccessIFrame"
              style={{display: "none"}} id="RequestStorageAccessIFrame">
              </iframe>
  1. Adding message listener in index.html:
<script>
        window.addEventListener("message", (event) => {
            // Ensure the message comes from IDP Origin iframe to prepare top level storage access permission.
            console.log("event ", event);
            if (event.origin === "https://local.tuando.com" && event.data.status === "endUserRequestAccessStorage") {
                document.getElementById("RequestStorageAccessIFrame").style.display = "block";
            }
            if (event.origin === "https://local.tuando.com" && event.data.status === "hasStorageAccessPermissionUI") {
                document.getElementById("RequestStorageAccessIFrame").style.display = "none";
            }
        });
    </script>

Complete flow of End User:

  1. User login with IDP.
  2. Application sucessfully login.
  3. IFrame shows with Request Storage Access button if IDP domain have not allowed by End User.
  4. Popup show up to ask User allows or denny storage access.
  5. User allows the access to cookie.
  6. Access Token Expiring event trigger sucessfully.
  7. As I remember the browser will ask after 30 days for allow access storage access again.

Remaining error: after successfully renew error is always shown
[Event('Window navigation aborted')] raise: Error: IFrame removed from DOM

@pamapa

  • I tried my best to patch the library code but i have to admit I can't understand all the flow within the library. I really need your help to have integrate with the library. I attached all the code and patches information to this issue as well.
  • Support for FedCM #1195 is not really needed right now. We can support this situation with this implementation.
  • 3rd party check in iframe not working anymore in safari and keycloak 21.1.2 keycloak/keycloak#21307 (comment) from Keycloak will be gone as well, Keycloak adapter already implemented storage access API, our implementation should work the same way.
  • Patch code in the library looks like this. I don't know how to upload the file directly.
diff --git a/node_modules/oidc-client-ts/dist/browser/oidc-client-ts.js b/node_modules/oidc-client-ts/dist/browser/oidc-client-ts.js
index cd25165..ececbf7 100644
--- a/node_modules/oidc-client-ts/dist/browser/oidc-client-ts.js
+++ b/node_modules/oidc-client-ts/dist/browser/oidc-client-ts.js
@@ -2119,8 +2119,14 @@ var oidc = (() => {
       if (!this._window) {
         throw new Error("Attempted to navigate on a disposed window");
       }
-      logger2.debug("setting URL in window");
-      this._window.location.replace(params.url);
+      window.addEventListener("message", (event) => {
+        // Ensure the message comes from IDP Origin iframe to prepare top level storage access permission.
+        if (event.origin === "https://local.tuando.com" && event.data.status === "storageAccessPermissionGranted" && this._window) {
+          console.log("Iframe script finished executing to request storage access ", event);
+          this._window.postMessage({redirectUrl: params.url}, "*");
+          // this._window.location.replace(params.url);
+        }
+      });
       const { url, keepOpen } = await new Promise((resolve, reject) => {
         const listener = (e) => {
           var _a;
@@ -2251,6 +2257,7 @@ var oidc = (() => {
       super();
       this._logger = new Logger("IFrameWindow");
       this._timeoutInSeconds = silentRequestTimeoutInSeconds;
+      // this._timeoutInSeconds = 200;
       this._frame = _IFrameWindow.createHiddenIframe();
       this._window = this._frame.contentWindow;
     }
@@ -2262,6 +2269,11 @@ var oidc = (() => {
       iframe.style.top = "0";
       iframe.width = "0";
       iframe.height = "0";
+      //This make sure the IFrame which load url from IDP allows to request top level storage access and User Activation to enable access 3rd party cookie.
+      iframe.sandbox = "allow-storage-access-by-user-activation allow-scripts allow-same-origin";
+      //link to IFrame from IDP which is used to request cookie access.
+      iframe.src="https://local.tuando.com/realms/UAARealm/uaa/silentRenewPlaceholderIFrame"
+      // iframe.id = "silentRenewIframe"
       window.document.body.appendChild(iframe);
       return iframe;
     }
diff --git a/node_modules/oidc-client-ts/dist/esm/oidc-client-ts.js b/node_modules/oidc-client-ts/dist/esm/oidc-client-ts.js
index 7e27a60..88db045 100644
--- a/node_modules/oidc-client-ts/dist/esm/oidc-client-ts.js
+++ b/node_modules/oidc-client-ts/dist/esm/oidc-client-ts.js
@@ -2019,8 +2019,14 @@ var AbstractChildWindow = class {
     if (!this._window) {
       throw new Error("Attempted to navigate on a disposed window");
     }
-    logger2.debug("setting URL in window");
-    this._window.location.replace(params.url);
+    window.addEventListener("message", (event) => {
+      // Ensure the message comes from IDP Origin iframe to prepare top level storage access permission.
+      if (event.origin === "https://local.tuando.com" && event.data.status === "storageAccessPermissionGranted" && this._window) {
+        console.log("Iframe script finished executing to request storage access ", event);
+        this._window.postMessage({redirectUrl: params.url}, "*");
+        // this._window.location.replace(params.url);
+      }
+    });
     const { url, keepOpen } = await new Promise((resolve, reject) => {
       const listener = (e) => {
         var _a;
@@ -2151,6 +2157,7 @@ var IFrameWindow = class _IFrameWindow extends AbstractChildWindow {
     super();
     this._logger = new Logger("IFrameWindow");
     this._timeoutInSeconds = silentRequestTimeoutInSeconds;
+    // this._timeoutInSeconds = 200;
     this._frame = _IFrameWindow.createHiddenIframe();
     this._window = this._frame.contentWindow;
   }
@@ -2162,6 +2169,11 @@ var IFrameWindow = class _IFrameWindow extends AbstractChildWindow {
     iframe.style.top = "0";
     iframe.width = "0";
     iframe.height = "0";
+    //This make sure the IFrame which load url from IDP allows to request top level storage access and User Activation to enable access 3rd party cookie.
+    iframe.sandbox = "allow-storage-access-by-user-activation allow-scripts allow-same-origin";
+    //link to IFrame from IDP which is used to request cookie access.
+    iframe.src="https://local.tuando.com/realms/UAARealm/uaa/silentRenewPlaceholderIFrame"
+    // iframe.id = "silentRenewIframe"
     window.document.body.appendChild(iframe);
     return iframe;
   }
diff --git a/node_modules/oidc-client-ts/dist/umd/oidc-client-ts.js b/node_modules/oidc-client-ts/dist/umd/oidc-client-ts.js
index 099b4be..abd1e6b 100644
--- a/node_modules/oidc-client-ts/dist/umd/oidc-client-ts.js
+++ b/node_modules/oidc-client-ts/dist/umd/oidc-client-ts.js
@@ -2064,8 +2064,14 @@ var AbstractChildWindow = class {
     if (!this._window) {
       throw new Error("Attempted to navigate on a disposed window");
     }
-    logger2.debug("setting URL in window");
-    this._window.location.replace(params.url);
+    window.addEventListener("message", (event) => {
+      // Ensure the message comes from IDP Origin iframe to prepare top level storage access permission.
+      if (event.origin === "https://local.tuando.com" && event.data.status === "storageAccessPermissionGranted" && this._window) {
+        console.log("Iframe script finished executing to request storage access ", event);
+        this._window.postMessage({redirectUrl: params.url}, "*");
+        // this._window.location.replace(params.url);
+      }
+    });
     const { url, keepOpen } = await new Promise((resolve, reject) => {
       const listener = (e) => {
         var _a;
@@ -2196,6 +2202,7 @@ var IFrameWindow = class _IFrameWindow extends AbstractChildWindow {
     super();
     this._logger = new Logger("IFrameWindow");
     this._timeoutInSeconds = silentRequestTimeoutInSeconds;
+    // this._timeoutInSeconds = 200;
     this._frame = _IFrameWindow.createHiddenIframe();
     this._window = this._frame.contentWindow;
   }
@@ -2207,6 +2214,11 @@ var IFrameWindow = class _IFrameWindow extends AbstractChildWindow {
     iframe.style.top = "0";
     iframe.width = "0";
     iframe.height = "0";
+    //This make sure the IFrame which load url from IDP allows to request top level storage access and User Activation to enable access 3rd party cookie.
+    iframe.sandbox = "allow-storage-access-by-user-activation allow-scripts allow-same-origin";
+    //link to IFrame from IDP which is used to request cookie access.
+    iframe.src="https://local.tuando.com/realms/UAARealm/uaa/silentRenewPlaceholderIFrame"
+    // iframe.id = "silentRenewIframe"
     window.document.body.appendChild(iframe);
     return iframe;
   }

If you need any information from my side please let me know, I will answer as soon as possible.

P/S: I'm sorry for mentioning you directly but this is really important for me and I really need your help.

@deanmaster deanmaster changed the title Silent Renew Process - Storage Access APIs - Working in Firefox Silent Renew Process - Storage Access APIs - PoC Working in Firefox Nov 7, 2024
@deanmaster
Copy link
Author

@pamapa could you please quickly have a look if we can change how the IFrame created please ? We really need to change the IFrame of silent_renew so that It can call Storage Access API from Browser :( especially
iframe.sandbox = "allow-storage-access-by-user-activation allow-scripts allow-same-origin";
and given an ID so that we can access later

@pamapa
Copy link
Member

pamapa commented Dec 12, 2024

The additional postMessage concept during the renew via iframe, makes everything even more complicated.

A different approach:
Maybe it is possible to request access to storage before hand? You could request it at the very beginning when the user clicks on "sign-in" button, before you call mgr.signinRedirect...

@pamapa
Copy link
Member

pamapa commented Dec 16, 2024

Yet another approach (which already works):
Using refresh token in combination with trusted device concept. Microsoft Entra ID is currently using this concept. In this library there is support for this within signinSilent. See

public async signinSilent(args: SigninSilentArgs = {}): Promise<User | null> {
const logger = this._logger.create("signinSilent");
const {
silentRequestTimeoutInSeconds,
...requestArgs
} = args;
// first determine if we have a refresh token, or need to use iframe
let user = await this._loadUser();
if (user?.refresh_token) {
logger.debug("using refresh token");
const state = new RefreshState(user as Required<User>);
return await this._useRefreshToken({
state,
redirect_uri: requestArgs.redirect_uri,
resource: requestArgs.resource,
extraTokenParams: requestArgs.extraTokenParams,
timeoutInSeconds: silentRequestTimeoutInSeconds,
});
}
let dpopJkt: string | undefined;
if (this.settings.dpop?.bind_authorization_code) {
dpopJkt = await this.generateDPoPJkt(this.settings.dpop);
}
const url = this.settings.silent_redirect_uri;
if (!url) {
logger.throw(new Error("No silent_redirect_uri configured"));
}
let verifySub: string | undefined;
if (user && this.settings.validateSubOnSilentRenew) {
logger.debug("subject prior to silent renew:", user.profile.sub);
verifySub = user.profile.sub;
}
const handle = await this._iframeNavigator.prepare({ silentRequestTimeoutInSeconds });
user = await this._signin({
request_type: "si:s",
redirect_uri: url,
prompt: "none",
id_token_hint: this.settings.includeIdTokenInSilentRenew ? user?.id_token : undefined,
dpopJkt,
...requestArgs,
}, handle, verifySub);
if (user) {
if (user.profile?.sub) {
logger.info("success, signed in subject", user.profile.sub);
}
else {
logger.info("no subject");
}
}
return user;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants