OAuth popup ~ Practical Guide
Francesco Di Donato

Francesco Di Donato @didof

About: Grant me the serenity to accept the scripts I cannot change, courage to change the scripts I can, and wisdom to know the best way to refactor them.

Location:
Bari
Joined:
Feb 12, 2020

OAuth popup ~ Practical Guide

Publish Date: Apr 27 '22
20 4

In a previous post we have implemented the GitHub OAuth flow from scratch. However, the redirection to the authentication page was via hard redirection, which is definitely not wanted behavior especially when you're dealing with a SPA.

Not surprisingly OAuth in the wild use a popup approach. Easy to solve, the redirection will still happen but in a spawned popup that, before dissolving will pass the access_token back to main tab.


This post is to be read as an enhancement of the previous one, so it has a slightly higher pacing. If in doubt, please refer to the accompanying code or the previous post.

<a id="oauth-github-popup">Authenticate via GitHub (popup)</a>
    <script>
      const CLIENT_ID = "Iv1.395930440f268143";

      const url = new URL("/login/oauth/authorize", "https://github.com");
      url.searchParams.set("client_id", CLIENT_ID);

      const cta = document.getElementById("oauth-github-popup");
      cta.setAttribute("href", url);
      cta.addEventListener("click", (event) => {
        event.preventDefault();

   // ...
Enter fullscreen mode Exit fullscreen mode

we assemble the GitHub OAuth authorization link and attacch it to the <a> tag for accessibility reason. However, we also listen for click on it, preventing the default hard redirection.

Next we are going to use the Web API window.open to spawn the popup.
It expect as third parameter a string containing width, height and more.

Personally, I prefer the explicity of an object that is then converted to the above mentioned string.

top and left properties have the auto value - that's not in the specifications of the API, in fact it is interpreted by the following snippet as an instruction to place in the center of the relative axis. Basically if both are auto the popup will always spawn in the center, even if you change width or height.

// in the 'click' eventListener callback
const features = {
  popup: "yes",
  width: 600,
  height: 700,
  top: "auto",
  left: "auto",
  toolbar: "no",
  menubar: "no",
};

const strWindowsFeatures = Object.entries(features)
  .reduce((str, [key, value]) => {
    if (value == "auto") {
      if (key === "top") {
        const v = Math.round(
          window.innerHeight / 2 - features.height / 2
        );
        str += `top=${v},`;
      } else if (key === "left") {
        const v = Math.round(
          window.innerWidth / 2 - features.width / 2
        );
        str += `left=${v},`;
      }
      return str;
    }

    str += `${key}=${value},`;
    return str;
  }, "")
  .slice(0, -1); // remove last ',' (comma)

window.open(url, "_blank", strWindowsFeatures);
Enter fullscreen mode Exit fullscreen mode

Thus, the user clicks your fancy GitHub button and authenticate via the popup. But it is important to instruct the server to send back to some page whose function is:

  1. Ensure it is a popup
  2. Extrapolate access_token from query params
  3. Dispatch it to parent window (window.opener)
  4. Close itself

Remember: OAuth callback
Once the user gets authenticated, GitHub redirects to the Callback URL specified at OAuth App/GitHub App creation. This is addressed in more detail in the previous post.

server.get("/oauth/github/login/callback", async (request, reply) => {
  const { code } = request.query;

  const exchangeURL = new URL("login/oauth/access_token", "https://github.com");
  exchangeURL.searchParams.set("client_id", process.env.CLIENT_ID);
  exchangeURL.searchParams.set("client_secret", process.env.CLIENT_SECRET);
  exchangeURL.searchParams.set("code", code);

  const response = await axios.post(exchangeURL.toString(), null, {
    headers: {
      Accept: "application/json",
    },
  });

  const { access_token } = response.data;

  const redirectionURL = new URL("popup", "http://localhost:3000");
  redirectionURL.searchParams.set("access_token", access_token);

  reply.status(302).header("Location", redirectionURL).send();
});

server.get("/popup", (request, reply) => {
  return reply.sendFile("popup.html");
});
Enter fullscreen mode Exit fullscreen mode

The client gets redirected to /popup and popup.html is shown.

<script>
    if (window.opener == null) window.location = "/";

    const access_token = new URL(window.location).searchParams.get(
    "access_token"
    );

    window.opener.postMessage(access_token);

    window.close();
</script>
Enter fullscreen mode Exit fullscreen mode

window.opener is null if the page has not been opened via the window.open. This way, if the user directly routes to /popup, gets redirected to /.

The computation is minimal, it should be pretty fast. Showing a spinner though can only do you good.

Tip
SPA and their router solutions offers some beforeEnter method that can be associated with a route; well, you could check the window.opener value in there, in order to provide an even better experience.

Almost done! The popup is dashing the access_token back to the parent but it is not listening!

window.addEventListener("message", receiveMessage, false);

function receiveMessage(event) {
  if (event.origin !== window.location.origin) {
    console.warn(`Message received by ${event.origin}; IGNORED.`);
    return;
  }

  const access_token = event.data;
}
Enter fullscreen mode Exit fullscreen mode

As a precaution ignore any message coming from another origin. Please, consider removeEventListener in your code.
Save the access_token somewhere. From this point the flow rejoins the previous post.

There's nothing stopping you from using this pattern for GitHub App installation and permissions changes as well.


Related Posts

Contacts

Comments 4 total

  • Jacob Fletcher
    Jacob FletcherFeb 20, 2023

    Exactly what I needed. Thank you!

  • lionel-rowe
    lionel-roweJun 21, 2023

    Great article, thanks! I'm using it as a basis for implementing OAuth in my app. I'm using this simplified version to construct the windowFeatures string for the popup:

    const popupDefaults = {
        popup: 'yes',
        toolbar: 'no',
        menubar: 'no',
        width: 600,
        height: 700,
    }
    
    const popupFeatures = (props) => {
        props = { ...popupDefaults, ...props }
        const { width, height } = props
        const { outerHeight, outerWidth } = window
        const top = Math.round(outerHeight / 2 - height / 2)
        const left = Math.round(outerWidth / 2 - width / 2)
    
        return Object.entries({ top, left, ...props })
            .map(([k, v]) => `${k}=${v}`)
            .join(',')
    }
    
    Enter fullscreen mode Exit fullscreen mode

    map + join is a bit more readable than reduce + nested if + slice, and abstracting it to a function allows you to override the various properties at will. Also, window.outerHeight and window.outerWidth make more sense to use than window.innerHeight and window.innerWidth, as top and left are calculated based on the window as a whole, rather than just the viewport area.

  • minghuiyang
    minghuiyangNov 15, 2023

    HI there, I am doing the same thing but Gitlab's human check will cause popup window lose opener and unable to postMessage. How did u deal with this problem ?

  • Marc Logemann
    Marc LogemannFeb 14, 2024

    great stuff. Especially the postMessage() call.

Add comment