@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
Manifest permissions
identityis needed foridentity.launchWebAuthFlowandidentity.getRedirectURL.storageis needed forstorage.localandstorage.session.
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:
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
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.
Popup and content script
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.idequal to the extension’s own id. Anything else is answered with amessage_rejectederror. - Messages from other extensions arrive on
runtime.onMessageExternal. That listener is registered only when you passallowedExtensionIds, 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 throwsExtensionAuthError 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:- An in-memory promise, so concurrent callers in one worker share one refresh.
- A lock record in
storage.localwith an expiry, so a second instance waits for the first and then reads the new tokens instead of spending the refresh token again.
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
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 fakeidentity 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.launchWebAuthFlowandstorage.sessionexist (the latter from Firefox 115), and its redirect URL is onextensions.allizom.orgwith a value derived frombrowser_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 ownidentityobject (theidentityoption takes any object withlaunchWebAuthFlowandgetRedirectURL) 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 asauthorization_failed.
Limits
- The access token is readable by any code in the extension background. There is no hardware backing for the refresh token.
onChangefires inside the context that made the change. Other contexts learn about a sign out on their next call, which will fail withnot_signed_in.- ID token claims are decoded without verifying the signature. The token is received directly from the token endpoint over TLS and its
nonceis 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.