Chrome Extension (Manifest v3) - using Auth0 in a secure manner

I’m building a Chrome Extension using Manifest V3, and I need to implement Auth0 so users can log in. This will enable us to make secure API calls to our backend.

All extension components, including the popup and multiple content scripts, would message the background worker to make API calls.

From my research, there are primarily three ways to do it:

1. Login with Auth0 directly in the Chrome Extension service worker

Problem → since service workers are not persistent, we need to store the access token in the Storage API or keep it in memory

Both options are vulnerable to XSS attacks

2. Log in with Auth0 in a dedicated web app using the Auth0 SDK, and message the web app to silently obtain the access token

For example, I have a NextJS web app where I use the @auth0/auth0-react package.
There is a provided getAccessTokenSilently() method we can use to obtain the access token.

The flow would then be:

  • The user opens the Chrome Extension popup, or a content script is injected into a page.
  • They message a service worker.
  • The service worker messages the web app to obtain the access token (using the getAccessTokenSilently() method).
  • The web app message handler sends the access token back to the extension service worker.
  • We then use it to make an API call in the service worker and never store the token (message the web app for every API call)

Problem → I’m not sure if this is secure enough
Would something like this work @dan.woda @robertino.calcaterra ?

3. Use the Token Handler Pattern
References:

Problem → it requires a lot of work to set up and additional backend resources

I think including examples in the Auth0 docs would be very useful, as it appears quite easy to encounter security breaches with Chrome Extensions and Manifest V3

I don’t think there’s an ideal solution due to limited functionality in browser extensions. That said…

since service workers are not persistent, we need to store the access token in the Storage API or keep it in memory. Both options are vulnerable to XSS attacks.

Keep in mind that the browser extension storage API is not the same as local/session storage. It is still not “secure” by any means but just because a bad actor has access to a page, i.e. via XSS, doesn’t mean that they can also access the storage API (or background script/service worker) like they could with local or session storage.

There is a provided getAccessTokenSilently() method we can use to obtain the access token.

To my knowledge, the getAccessTokenSilently() method starts the authentication flow in an iframe (“third-party”) and then sends the access token back to the “parent” (in your case the chrome extension). As you’ve mentioned, this means that you can’t directly use this method in a background script or service worker but instead need to rely on a content script.

Additionally, there are already various limitations in certain browsers that restrict what cookies an iframe can access. As part of their privacy sandbox initiative, Chrome will also introduce additional measures to limit third-party cookies. Auth0 could potentially use “partitioned” cookies (CHIPS, in case they don’t already use them… haven’t checked) to work around some of these restrictions but the auth session is then not the same session as the one your web app is using.

To keep longer auth sessions, you can also think about rotating refresh tokens. You would need to store these tokens in the extension storage API (see above) but they would allow you keep users logged in for longer without having to increase the access token expiration. Due to how these work, users will get logged out automatically if they use multiple instances of your browser extension, though.

Problem → it requires a lot of work to set up and additional backend resources

It also relies on the token handler endpoint and your extension to run on the same domain because both need to access the same (HTTP only) cookies. To get close to that, you could spawn your web app (which needs to run on the same domain as the token handler endpoint) in a new window, do all the auth stuff there and then send the tokens back to your background script/service worker. But since you need the help of your web app anyway, the token handler pattern would then no longer serve any real purpose.

Instead, you could directly deal with all the auth related stuff in the server side part of your Next.js application. So, something like this:

  1. The background script/service worker creates a new window (not focused, in the background) which points to a route in your Next.js web application.
  2. On this Next.js route (server-side), you get an access token (or refresh it in case it is expired).
  3. You expose this access token to the client side, so that your content script can access it.
  4. The content script sends the access token back to the background script/service worker.
  5. The background script/service worker closes the window and stores this token in memory.

Whenever the background script or service worker need a new access token, e.g. due to the service worker shutting down, you repeat the same process. I’m not sure if this (especially point 3) is any better than directly using the extension storage API (see above). I’m also not sure how opening and closing the window affects the user experience—the service worker in the manifest v3 shuts down fairly quickly, so you would need to repeat this process often.

Thank you for the complete answer.

Another option I’m exploring is option 1:

1. Login with Auth0 directly in the Chrome Extension service worker

  • using the Authorization Code Flow with PKCE, we can get an access token in a service worker, and prompt the user to log in if needed

However, instead of storing the access token in storage, we keep it in a global variable.

This way, it can be reused (and avoid increased load on the Auth0 servers and slower response times if we request a new access token for each API call) as long as the service worker is active. Otherwise, we fetch another access token from Auth0

Would that be a good tradeoff @mrksbnch ?

It may, but please note that you can’t directly fetch an access token. This requires a flow, e.g. the authorization code flow, which requires user interaction unless you are using silent authentication.

The launchWebAuthFlow method in browser extensions support a interactive flag which may work in that particular case. I have never tried that but let us know if this works in combination with Auth0.

Strange

In my code where I use the Authorization Code Flow with PKCE, calling the function multiple times doesn’t re-prompt the user to sign in every time

Why is that?

async function promptUserLogin(): Promise<string> {

  const redirectUrl = chrome.identity.getRedirectURL()

  const inputBytes = getRandomBytes()
  const verifier = buf2Base64(inputBytes)

  const shaHash = await sha256(verifier)
  const codeChallenge = buf2Base64(shaHash)

  let options = {
    client_id: process.env.PLASMO_PUBLIC_AUTH0_CLIENT_ID,
    redirect_uri: redirectUrl,
    response_type: "code",
    audience: process.env.PLASMO_PUBLIC_AUTH0_AUDIENCE,
    scope: "openid",
    code_challenge: codeChallenge,
    code_challenge_method: "S256"
  }

  let resultUrl: string = await new Promise((resolve, reject) => {
    let queryString = new URLSearchParams(options).toString()
    let url = `https://${process.env.PLASMO_PUBLIC_AUTH0_DOMAIN}/authorize?${queryString}`
    console.log("url", url)
    chrome.identity.launchWebAuthFlow(
      {
        url,
        interactive: true
      },
      (callbackUrl) => {
        resolve(callbackUrl)
      }
    )
  })

  if (resultUrl) {
    const code = getParameterByName("code", resultUrl)
    console.log("code", code)

    const body = JSON.stringify({
      redirect_uri: redirectUrl,
      grant_type: "authorization_code",
      client_id: process.env.PLASMO_PUBLIC_AUTH0_CLIENT_ID,
      code_verifier: verifier,
      code: code
    })

    const response = await fetch(
      `https://${process.env.PLASMO_PUBLIC_AUTH0_DOMAIN}/oauth/token`,
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: body
      }
    )

    if (!response.ok) {
      throw new Error("Network response was not ok")
    }

    const result = await response.json()
    console.log("result", result)

    if (result && result.access_token && result.expires_in) {
      return result.access_token
    } else {
      console.log("Auth0 Authentication Data was invalid")
    }
  } else {
    console.log("Auth0 Cancelled or error. resultUrl", resultUrl)
  }
}

Ah yes the code use LaunchWebAuthFlow method, seems to work

Hi, Have you fixed this

calling the function multiple times doesn’t re-prompt the user to sign in every time

issue?