Skip to main content
@glinr/theauth-extension signs a user into your extension against a TheAuth authorization server, using the authorization code flow with PKCE (S256). It keeps the access token fresh across service worker restarts and gives content scripts and popups a way to ask the background for a token without ever seeing the refresh token. It has no runtime dependencies. DPoP support uses @glinr/theauth-client, which is an optional peer.

Install

The package ships ES modules. Bundle it into your background script with whatever bundler you already use (esbuild, Vite, webpack). Extensions cannot load code from a CDN under Manifest V3.

Manifest permissions

  • identity is needed for identity.launchWebAuthFlow and identity.getRedirectURL.
  • storage is needed for storage.local and storage.session.
No host permission is required for the sign in itself. Calls from the background to your authorization server and API are subject to that server’s CORS policy unless you add host_permissions for it.

Register the redirect URI

identity.getRedirectURL() returns a URL on a browser-controlled domain. On Chrome it looks like https://<extension-id>.chromiumapp.org/. Register exactly that value as a redirect URI for your OAuth client:
The extension id changes between an unpacked build and the Web Store build unless you pin it with a key in the manifest. Register each redirect URI you use, or pin the key. Register the client as a public client (token_endpoint_auth_method: "none") with the authorization_code and refresh_token grants. An extension cannot keep a client secret.

Background script

Create the auth object at the top level of the service worker, not inside an event handler. Every wakeup runs the script again, builds a new object and reads its state from storage. createExtensionAuth options: Returned object: signIn(), getAccessToken(), getSession(), signOut(), onChange(cb) and fetch(url, init). fetch attaches the right Authorization scheme, refreshes first when needed and handles DPoP nonce challenges.
Content scripts can call getAccessToken and getSession. signIn and signOut are accepted only from extension pages such as the popup and options page, because a content script runs next to untrusted page content. If you enabled DPoP, do not call the resource from a content script with the token alone: do the request in the background with auth.fetch, since the key never leaves it. Sender checks:
  • A message must carry sender.id equal to the extension’s own id. Anything else is answered with a message_rejected error.
  • Messages from other extensions arrive on runtime.onMessageExternal. That listener is registered only when you pass allowedExtensionIds, and only those ids are served.
  • senderFilter(sender, method) lets you add your own rule, for example to restrict content scripts to specific origins.

Errors

Every method throws ExtensionAuthError with a code: On invalid_grant from the token endpoint the package clears all stored state, calls onChange(null) and throws refresh_failed. Transient failures (network, 5xx) keep the state so the next call can retry.

Storage and the refresh token

If storage.session does not exist in the browser, the access token is held in memory only and the worker refreshes after each restart. With encryptRefreshToken: true the refresh token is sealed with AES-GCM before it is written. The key is a non-extractable CryptoKey kept in IndexedDB, and the ciphertext is bound to its storage slot, so a value copied to another key fails to open and a modified value is rejected before any request is sent. This is defence in depth, not a vault. Extension storage and IndexedDB are readable by your extension’s own code, and so by anything that compromises it, including a supply chain attack on one of your dependencies. A non-extractable key stops that code from copying the key bytes, but it can still ask the browser to decrypt. What the sealing does protect against is disk-level reads of the profile, backup and sync tools that copy storage.local, and bugs that leak the storage object. Keep refresh token lifetimes short, rotate them on use and turn on revocation on the server.

Refresh in a service worker

The worker can be stopped at any time and two copies of your code can briefly overlap (a popup page importing the same module, or a wakeup racing a shutdown). The package handles this in two layers:
  1. An in-memory promise, so concurrent callers in one worker share one refresh.
  2. A lock record in storage.local with an expiry, so a second instance waits for the first and then reads the new tokens instead of spending the refresh token again.
The extension storage API has no compare-and-set, so the lock is write then read back. Two contexts that write in the same instant can both believe they hold it. With refresh token rotation the loser gets invalid_grant and the user is signed out. Servers that allow a short grace period for a just-rotated token avoid this. The expiry means a worker killed mid-refresh does not block sign in for more than lockTtlMs. A worker killed during the interactive sign in window loses the pending sign in. The user sees user_cancelled or nothing, and starts again.

DPoP

An ES256 key pair is generated with a non-extractable private key and stored in IndexedDB, so the same key is used after a restart. Token and refresh requests carry a DPoP proof, resource requests made through auth.fetch get Authorization: DPoP <token> with a proof bound to the token (ath), and a use_dpop_nonce challenge is retried once. Pass the module yourself when you bundle the worker. With dpop: true the package falls back to a dynamic import() of @glinr/theauth-client, which service workers do not allow. See DPoP and requireMcpAuth for the server side.

Sign out

signOut() posts the refresh token to the revocation endpoint when the server advertises one (RFC 7009), clears storage, emits null and returns { revoked }. A failed revocation does not stop the local sign out. With endSession: true it also loads end_session_endpoint with id_token_hint and post_logout_redirect_uri set to the extension redirect URL, without showing a window.

Browser support

Tested here: the package against fake identity and storage objects in Node, and against the in-process MCP authorization server from @glinr/theauth. It has not been run inside a real browser yet.
  • Chrome and other Chromium browsers (Edge, Brave): the API shape comes from the Chrome documentation. UNVERIFIED in a live browser.
  • Firefox: identity.launchWebAuthFlow and storage.session exist (the latter from Firefox 115), and its redirect URL is on extensions.allizom.org with a value derived from browser_specific_settings.gecko.id, so set that id. Firefox runs Manifest V3 background code as an event page rather than a service worker. UNVERIFIED.
  • Safari: We believe Safari web extensions do not provide identity.launchWebAuthFlow. If that holds, pass your own identity object (the identity option takes any object with launchWebAuthFlow and getRedirectURL) backed by a tab or a native bridge. UNVERIFIED.
  • Chrome returns a rejected promise with the text “The user did not approve access.” when the window is closed. The package treats messages matching cancel, closed, dismiss, denied or “did not approve” as user_cancelled. Other browsers word this differently and may surface as authorization_failed.

Limits

  • The access token is readable by any code in the extension background. There is no hardware backing for the refresh token.
  • onChange fires inside the context that made the change. Other contexts learn about a sign out on their next call, which will fail with not_signed_in.
  • ID token claims are decoded without verifying the signature. The token is received directly from the token endpoint over TLS and its nonce is checked, which is what OpenID Connect Core allows. Do not forward the ID token to another service as proof of identity.
  • Sign in needs identity.launchWebAuthFlow, which opens a browser-managed window. It does not work in a headless profile.
Last modified on October 13, 2026