Supabase login with OAuth in Chrome extensions

Supabase login with OAuth in Chrome extensions

One of my recent projects is a Chrome extension where I want to offer users the possibility to log in with different OAuth providers like Facebook, Twitter and Google. I decided to go with Supabase for implementing authentication since it's fairly easy to set up and free for small projects like this one.

I've implemented login with OAuth many times in other websites, but this was my first Chrome extension and I was not expecting a simple user login to be this much of a headache to implement.

The initial attempt

The Supabase docs on logging in with Google (which has the first one I was trying to implement) have a special section on Chrome extensions with a nice code sample which you can paste into your app. It uses the identity API from the Chrome runtime to open a new window with the OAuth dialog from Google:

const manifest = chrome.runtime.getManifest()

const url = new URL('https://accounts.google.com/o/oauth2/auth')

url.searchParams.set('client_id', manifest.oauth2.client_id)
url.searchParams.set('response_type', 'id_token')
url.searchParams.set('access_type', 'offline')
url.searchParams.set('redirect_uri', `https://${chrome.runtime.id}.chromiumapp.org`)
url.searchParams.set('scope', manifest.oauth2.scopes.join(' '))

chrome.identity.launchWebAuthFlow(
  {
    url: url.href,
    interactive: true,
  },
  async (redirectedTo) => {
    if (chrome.runtime.lastError) {
      // auth was not successful
    } else {
      // auth was successful, extract the ID token from the redirectedTo URL
      const url = new URL(redirectedTo)
      const params = new URLSearchParams(url.hash)

      const { data, error } = await supabase.auth.signInWithIdToken({
        provider: 'google',
        token: params.get('id_token'),
      })
    }
  }
)

The secret sauce here is to use the chrome.identity.launchWebAuthFlow API. But there is one major flaw with it: your extension popup can close randomly. I cannot stress how annoying this is, not to mention that it can lead to users not being able to log into your app. If the popup closes (either by itself or if the user clicks the chrome window outside of it) then your code will no longer be invoked in the callback.

What eventually worked

After digging through StackOverflow and Reddit for a couple of days I finally managed to put together a working piece of code. It does change the UX a bit, but I think it's even better than opening up a new window for the OAuth dialog.

This is how the new UX flow works:

  1. user clicks on the login button

  2. the extension will open a new tab to Supabase's auth endpoint

  3. a background script will listen for when a tab is redirected to the chrome extension internal URL (more on this down below)

  4. save the Supabase token to local storage and redirect to a friendly*-er* page

  5. user needs to open the extension by clicking on it again and ... he is logged in

Popup.html

The first part of the code will be inside the extension popup where we need to generate the URL to Supabase's auth middleware:

/**
 * Method used to login with google provider.
 */
export async function loginWithGoogle() {
  const { data, error } = await supabase.auth.signInWithOAuth({
    provider: 'google',
    options: {
      redirectTo: chrome.identity.getRedirectURL(),
    },
  });
  if (error) throw error;

  await chrome.tabs.create({ url: data.url });
}

chrome.identity.getRedirectURL() will generate a URL that looks like https://your-extension-id.chromiumapp.org so make sure to add it as a valid redirect in the Supabase Auth dashboard.

Background.js

The second part of the code is the background script listener which will pick up the tokens generated by Supabase and save the session to local storage.

// add tab listener when background script starts
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
  if (changeInfo.url?.startsWith(chrome.identity.getRedirectURL())) {
    finishUserOAuth(changeInfo.url);
  }
});

/**
 * Method used to finish OAuth callback for a user authentication.
 */
async function finishUserOAuth(url: string) {
  try {
    console.log(`handling user OAuth callback ...`);
    const supabase = createClient(secrets.supabase.url, secrets.supabase.key);

    // extract tokens from hash
    const hashMap = parseUrlHash(url);
    const access_token = hashMap.get('access_token');
    const refresh_token = hashMap.get('refresh_token');
    if (!access_token || !refresh_token) {
      throw new Error(`no supabase tokens found in URL hash`);
    }

    // check if they work
    const { data, error } = await supabase.auth.setSession({
      access_token,
      refresh_token,
    });
    if (error) throw error;

    // persist session to storage
    await chrome.storage.local.set({ session: data.session });

    // finally redirect to a post oauth page
    chrome.tabs.update({url: "https://myapp.com/user-login-success/"});

    console.log(`finished handling user OAuth callback`);
  } catch (error) {
    console.error(error);
  }
}

/**
 * Helper method used to parse the hash of a redirect URL.
 */
function parseUrlHash(url: string) {
  const hashParts = new URL(url).hash.slice(1).split('&');
  const hashMap = new Map(
    hashParts.map((part) => {
      const [name, value] = part.split('=');
      return [name, value];
    })
  );

  return hashMap;
}

To make the UX nicer after saving the session I redirect the user to a static page which informs him that he is now logged in and with an arrow that points to the extensions slot in Chrome to open up the popup again.

Also, you need to add these permissions to your manifest for this code to work:

"permissions": [
  "identity",
  "tabs",
  "storage" 
]

Now, when your extension opens check if you have a session and try to use it to fetch the logged-in user:

const { session } = await chrome.storage.local.get('session');
if (session) {
  const { error: supaAuthError } = await supabase.auth.setSession(
    session
  );
  if (supaAuthError) {
    throw supaAuthError;
  }

  navigate('/home');
}

Hopefully, this will save someone else a few hours of frustration :)