The quest for auth in the modern web app
Tags: auth, javascript, pwa, webdev
Published: 2022-10-25 08:18:55

From ye old days of HTML table layouts, web apps have grown tremendously sophisticated, to paralleling native app capabilities and performance in, dare I say, most use-cases (push notifications on iOS aside, for now <sup>[[1]](#footnote-1)</sup>).

There are a number of authentication methods available nowadays, but let's first review the main challenges common to them all.

## Prologue

A scene unfolds before you: a torrent of requests and responses flying back and forth to one place and another. Some venturing to the wide world, and some back to HQ. The ones back to HQ need the secret knock.

### Challenge 1: Session

You don't want just anybody wondering into HQ, so to prevent that you have a secret knock; but, you also need to prevent some rando figuring out your secret knock, so you decide to change it regularly. This is the essentials of basically any authentication setup.

To mitigate the chances of a bad actor hijaking a user's active session, apps typically have a short-lived "access" token that expires after a few (~15) minutes. When that happens, most requests subsequently made to the app's server-side component(s) are rejected. This is an easily recoverable scenario. But what do you do when this happens?

Apps typically have a second, "refresh", token. The only thing this token can be used to do is request a new access token. This refresh token is typically longer-lived, at a minimum, longer than the access token (otherwise, it's useless). When the refresh token expires, that is, by design, unrecoverable (without user intervention).

Maybe you're thinking to yourself: "I got this". But suddenly, another token appears, and then another. "Where the heck are all these tokens coming from??"

### Challenge 2: Plot twist! (enter the multiverse)

"Oh. my. god." You've realised users have your app running in multiple browser tabs (as many users do—the official term for these app instances is "clients").

The door to the multiverse is thrown wide open: These tokens are coming from different browser tabs.

This is actually classic concurrency. The issues here to face are most notably desynchronisation of shared data. In the case of authentication, that means the access and refresh tokens (the secret knock). If a client (an SPA running in a particular browser tab) keeps a copy of the tokens in its memory (eg `const tokens = { access: '…', refresh: '…' }`), that can become stale when another tab is updated. This is a mistake that I see most of the time, and it will result in confusing bugs.

The mitigation I see most often is keeping the tokens in a shared place and retrieve them on every request. This is, however, very inefficient, and depending on what shared place the tokens are put, it may not actually address the issue (ex putting them in [`IndexedDb`](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) re-creates the same problem, but now much more complicated).

## Deus ex machina

You could address the first challenge fairly easily in a number of ways, but the multiverse makes things much trickier. However, there is a solution that addresses all of these at the same time with basically **no extra work**: A [`ServiceWorker`](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)<sup>[[2]](#footnote-2)</sup> (which you may or may not already be using, eg for a [PWA](https://web.dev/progressive-web-apps/)). The reason for this is because this is what it was intended to do: it creates a point of commonality for multi-client scenarios as a single ServiceWorker shared by all clients. I recently implemented this with @iainjreid for [Orbiit](https://www.orbiit.ai) (who donated this example): [JakobJingleheimer/webapp-auth-via-serviceworker](https://www.github.com/JakobJingleheimer/webapp-auth-via-serviceworker) (verified on 24 Oct in the latest Chrome <sup>[[3]](#footnote-3)</sup>, Firefox, and iOS Safari).

The important pieces to note are:

* [WebStorage](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API) (specifically, `LocalStorage`) because WebStorage is _synchronous_ and locking (multiple clients cannot write at the same time—reads and writes are queued). This is critical for precluding race conditions and desynchronisation.
* A `fetch` event listener in the ServiceWorker to decorate _internal_ requests with authentication and handle the various session scenarios cited above.
* The ServiceWorker maintains the in-memory cache of tokens; this is safe because all roads lead to Rome (all requests go through the same ServiceWorker and its cache).
* The SPA is blocked from initialising until the ServiceWorker is ready. This is necessary to avoid any requests the SPA might make before the ServiceWorker can intercept them. The provided ServiceWorker is quite small (and it's important to keep it that way!), so the latency incurred waiting for it to activate is trivial (something like 3 ticks).

### Fetch quest

The fetch handler in the provided example does a few things:

* Ignores external requests (you don't want to tell the secret knock to some rando).
* Detects transient authentication failures in internal requests, automatically triggering a refresh (if possible), and then re-tries the original request.
* Detects and surfaces to the client unrecoverable authentication failures. This allows the client to consider any `401` response a cue to tell the user to log in again.

There is one minor issue with the provided implementation, which is fortunately limited to <abbr title="developer experience">DX</abbr>: Handling a request doomed to fail. I think the most correct way to handle this scenario is to cancel it via [`AbortController::abort()`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort) because it causes the request in DevTools' Network panel to be marked `cancelled`, which seems to me the most accurate representation of what has happened (the ServiceWorker has detected the request is doomed to fail and has cancelled it without hitting the network); this method also supports providing a reason. However, due to identical bugs in all major browsers <sup>[[4]](#footnote-4)[[5]](#footnote-5)[[6]](#footnote-6)</sup>, this does not work.

This leaves 2 options that achieve the same end-result (but both create red herrings for the developer):

* Use AbortSignal and trigger a "network failure". This can look like an environment problem instead of expected behaviour.
* Return a fake failing response (eg `401`). This looks like the server responded with the failure (when in fact the request never left and never touched the server); also, this option makes it more difficult for the client (SPA) to determine what to do (now it has no way to differentiate a real problem from one already being remediated).

I opted for the former (use AbortSignal despite the misleading "network failure") because it has fewer downsides, is a bit less misleading than the other option, and should just start working when the browser bugs are fixed.

## Epilogue

This quest started ~4 years ago when I originally implemented it for another client. With the latest refinement (blocking the SPA loading until the ServiceWorker is ready), I believe I've addressed all the core concerns, but if you have any unaddressed, I'd love to hear about it!

## Footnotes

1. <a name="footnote-1"></a> Apple claim they "can't" support Push Notifications in PWAs for "performance reasons". Android and desktops somehow managed to address software performance issues years ago, so one might ask "Are Apple's engineers significantly less capable than everyone else?". We can be pretty confident Apple's engineers are not inept, so one might think the next plausible explanation is Apple are lying: It's a problem of _financial_ performance. Push Notifications are effectively a "must" for many apps, so this forces app developers to publish on the AppStore, which is hugely financially advantageous for Apple: Apple take a 30% cut of payments (which they have enforced as a requirement for inclusion in the AppStore). Fortunately, many governments around the world (ex [EU](https://www.nytimes.com/2022/05/02/business/apple-pay-antitrust-eu.html), [South Korea](https://apnews.com/article/technology-business-south-korea-a8e160fb9b43681557445cfe06f25bc1), [US](https://www.npr.org/2021/09/10/1036043886/apple-fortnite-epic-games-ruling-explained)) have ruled Apple are violating anti-monopoly laws, and Apple must allow app developers to use the payment service of the developer's choice. So without financial or software performance issues, we might soon see this "performance reasons" issue disappear (unless Apple decide to vindictively keep up the ruse, and the world leaves them behind).
1. <a name="footnote-2"></a> When working with a ServiceWorker, ensure you
  1. Always use an Incognito window (stick to Chromium for dev—Firefox does not allow ServiceWorkers in Private mode aka an Incognito window)
  1. Enable DevTools → Application → Service Workers → `Update on reload`.
  
    If you don't do both of these, you will have a very bad day.
1. <a name="footnote-3"></a> [A bug in Brave](https://github.com/brave/brave-browser/issues/25855) causes any ServiceWorker `fetch()` of a CORS request to fail with `blocked:other` (regardless of proper CORS setup). That means this solution does not _currently_ work at all in Brave (they're pretty quick to fix bugs).
1. <a name="footnote-4"></a> [Chromium ServiceWorker fetch AbortSignal bug](https://bugs.chromium.org/p/chromium/issues/detail?id=1377650)
1. <a name="footnote-5"></a> [Firefox ServiceWorker fetch AbortSignal bug](https://bugzilla.mozilla.org/show_bug.cgi?id=1796998)
1. <a name="footnote-6"></a> Safari just ignore bug reports, so I didn't bother.