Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
import type ApplicationInstance from '@ember/application/instance';

import { registerAuthServiceWorker } from '../utils/auth-service-worker-registration';
import {
isServiceWorkerSupported,
registerAuthServiceWorker,
} from '../utils/auth-service-worker-registration';

import type MatrixService from '../services/matrix-service';
import type RealmService from '../services/realm';

// Register the auth service worker eagerly at app boot, before any lazy
// services are instantiated. This ensures realm tokens are synced to the
// SW before card rendering triggers image requests to authenticated realms.
export function initialize(_appInstance: ApplicationInstance): void {
registerAuthServiceWorker();
export function initialize(appInstance: ApplicationInstance): void {
// Gate before lookup so we don't force eager instantiation of matrix /
// realm services in tests or non-SW environments.
if (!isServiceWorkerSupported()) {
return;
}
let matrixService = appInstance.lookup('service:matrix-service') as
| MatrixService
| undefined;
let realmService = appInstance.lookup('service:realm') as
| RealmService
| undefined;
if (!matrixService || !realmService) {
return;
}
registerAuthServiceWorker({ matrixService, realmService });
}

export default {
Expand Down
104 changes: 102 additions & 2 deletions packages/host/app/utils/auth-service-worker-registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,28 @@ import window from 'ember-window-mock';

import { SessionLocalStorageKey } from './local-storage-keys';

function isServiceWorkerSupported(): boolean {
// Structural so tests can stub without the full service surface.
export interface AuthServiceWorkerDeps {
realmService: {
realmOf(input: URL): string | undefined;
reauthenticate(realmURL: string): Promise<string | undefined>;
};
matrixService: {
readonly isLoggedIn: boolean;
};
}

export function isServiceWorkerSupported(): boolean {
return (
!isTesting() &&
typeof navigator !== 'undefined' &&
'serviceWorker' in navigator
);
}

export async function registerAuthServiceWorker(): Promise<void> {
export async function registerAuthServiceWorker(
deps: AuthServiceWorkerDeps,
): Promise<void> {
if (!isServiceWorkerSupported()) {
return;
}
Expand All @@ -32,6 +45,11 @@ export async function registerAuthServiceWorker(): Promise<void> {
}
});

navigator.serviceWorker.addEventListener(
'message',
createTokenRequestHandler(deps),
);

try {
await navigator.serviceWorker.register('/auth-service-worker.js', {
scope: '/',
Expand All @@ -50,6 +68,58 @@ export async function registerAuthServiceWorker(): Promise<void> {
}
}

export function createTokenRequestHandler(deps: AuthServiceWorkerDeps) {
return async (event: MessageEvent) => {
if (!event.data || event.data.type !== 'request-realm-token') {
return;
}
let port = event.ports?.[0];
if (!port) {
return;
}
let requestURL: string | undefined = event.data.requestURL;

let { realmURL, token } = resolveTokenForRequestURL(requestURL);
if (realmURL && token) {
port.postMessage({ realmURL, token });
return;
}

let owningRealm: string | undefined;
if (requestURL) {
try {
owningRealm = deps.realmService.realmOf(new URL(requestURL));
} catch {
owningRealm = undefined;
}
}
if (!owningRealm || !deps.matrixService.isLoggedIn) {
port.postMessage({});
return;
}

// Tell the SW to use its refresh budget; reauthenticate is single-flighted
// per realm and syncs the new token to the SW as a side effect.
try {
port.postMessage({ type: 'pending' });
} catch {
return;
}
try {
let refreshed = await deps.realmService.reauthenticate(owningRealm);
port.postMessage(
refreshed ? { realmURL: owningRealm, token: refreshed } : {},
);
} catch {
try {
port.postMessage({});
} catch {
// port closed (page navigated)
}
}
};
}

export function syncTokenToServiceWorker(
realmURL: string,
token: string | undefined,
Expand Down Expand Up @@ -121,3 +191,33 @@ function readTokensFromStorage(): Record<string, string> | undefined {
}
return undefined;
}

// Find the longest realm-URL prefix in localStorage that matches the given
// request URL. Returns `undefined` for both fields when nothing matches —
// the SW will then preserve its existing pass-through behavior for that
// request.
function resolveTokenForRequestURL(requestURL: string | undefined): {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if you need to get a new token here? since this is sync I don't think you can do that--but more deeply, this is a callback for an event which needs a sync function. handling an async mechansim to get a new token from the server will be a little tricky to wire thru properly....

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah good point. Added changes that make the handler async and call realmService.reauthenticate on a localStorage miss. The wiring turned out simpler than expected since reauthenticate already persists + syncs the new token back to the SW as a side effect.

realmURL?: string;
token?: string;
} {
if (!requestURL) {
return {};
}
let tokens = readTokensFromStorage();
if (!tokens) {
return {};
}
let bestRealmURL: string | undefined;
for (let realmURL of Object.keys(tokens)) {
if (
requestURL.startsWith(realmURL) &&
(!bestRealmURL || realmURL.length > bestRealmURL.length)
) {
bestRealmURL = realmURL;
}
}
if (!bestRealmURL) {
return {};
}
return { realmURL: bestRealmURL, token: tokens[bestRealmURL] };
}
Loading
Loading