didof.dev

OAuth popup ~ Practical Guide

OAuth popup ~ Practical Guide

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.


Where We Left Off

<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();

   // ...

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.

Using a popup

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.

// 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);

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
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");
});

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>

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.

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;
}

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