Skip to content
Open
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
353 changes: 197 additions & 156 deletions src/oauth-service-worker.js
Original file line number Diff line number Diff line change
@@ -1,184 +1,225 @@
// to immediately install the service worker
addEventListener("install", (event) => {
// install on all site tabs without waiting for them to be opened
skipWaiting();
});

// to immediately activate the service worker
addEventListener("activate", (event) => {
// activate on all tabs without waiting for them to be opened
event.waitUntil(clients.claim());
});

// to send a message to all clients
function sendMessage(message) {
self.clients.matchAll().then((clients) => {
clients.forEach((client) => {
client.postMessage(message);
});
});
}

// These Maps are indexed by the resource_server of the protected resource URLs.
// There will be one entry in each Map for each protected resource URL,
// which is provided by the user when calling the authenticate({...}) method.
const tokenExpirationStore = new Map();
const refreshTokenStore = new Map();
const tokenStore = new Map();
const configStore = new Map();

function clearToken(resource_server) {
try {
tokenExpirationStore.delete(resource_server);
refreshTokenStore.delete(resource_server);
tokenStore.delete(resource_server);
configStore.delete(resource_server);

sendMessage({ type: "accessTokenCleared" });
} catch (e) {
sendMessage({ type: "clearTokenError" });
}
/* eslint-disable @typescript-eslint/naming-convention */
importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.5.4/workbox-sw.js')
importScripts('https://cdn.jsdelivr.net/npm/idb@7/build/umd.js')

// Disable the annoying logging
workbox.setConfig({ debug: false })

workbox.loadModule('workbox-strategies')
workbox.loadModule('workbox-routing')
workbox.loadModule('workbox-expiration')

const { registerRoute, Route } = workbox.routing
const { NetworkOnly } = workbox.strategies

/**
* Get an instance of the database, creating the object stores when needed
*/
async function store() {
return await self.idb.openDB('pass-storage', 1, {
upgrade(db) {
db.createObjectStore('config')
db.createObjectStore('token_store')
}
})
}

self.addEventListener("message", (event) => {
const type = event.data.type;

switch (type) {
case "storeConfig":
configStore.set(event.data.config.resource_server, event.data.config);
break;
case "clearToken":
clearToken(event.data.resource_server);
break;
default:
console.log("type:", type, "not handled");
}
});

function refreshToken(configItem) {
const refreshToken = refreshTokenStore.get(configItem.resource_server);

const headers = new Headers();
headers.set("Content-Type", "application/x-www-form-urlencoded");
const body = new URLSearchParams();
body.set("grant_type", "refresh_token");
body.set("refresh_token", refreshToken);

return fetch(configItem.token_endpoint, {
method: "POST",
headers,
body,
});
/**
* Get the current time in seconds
*/
function currentTime() {
return Math.floor(Date.now() / 1000)
}

/**
* Update a headers object to include the token if required
* @param {Headers} headers - A headers object.
* @param {string} accessToken - The token to add if needed.
*/
function createHeaders(headers, accessToken) {
const newHeaders = new Headers(headers);
// Only add the Authorization header if the user hasn't added a custom one for a given protected resource URL.
if (!newHeaders.has("Authorization")) {
newHeaders.set("Authorization", `Bearer ${accessToken}`);
const newHeaders = new Headers(headers)
// Only add the Authorization header if the user hasn't added a custom one for
// a given protected resource URL.
if (!newHeaders.has('Authorization')) {
newHeaders.set('Authorization', `Bearer ${accessToken}`)
}
return newHeaders;
return newHeaders
}

function getTimestampInSeconds() {
return Math.floor(Date.now() / 1000);
/**
* Refresh the token against a given endpoint.
*
* Given as an argument to make it easier to later support multiple token stores
* @param {string} tokenEndpoint - A URL to query for refresh tokens.
*/
async function refreshToken(tokenEndpoint) {
try {
const db = await store()

sendMessage({ type: 'refreshingToken' })
return await fetch(tokenEndpoint, {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/x-www-form-urlencoded'
}),
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: await db.get('token_store', 'refresh_token')
})
})
} catch (e) {
console.error(e)
sendMessage({ type: 'refreshTokenError' })
}
}

async function handleTokenResponse(response, configItem) {
/**
* Parse the token response and store values as needed
*
* Also updates the expiry time and sends messages to other clients as needed
* @param {Response} originalResponse - A token response to parse. This is
* cloned to get around the body being locked when we read it.
*/
async function handleTokenResponse(originalResponse) {
try {
const status = response.status;
const response = originalResponse.clone()
const db = await store()

if (status >= 400 && status < 600) {
throw new Error("Token request failed");
if (response.status >= 400 && response.status < 600) {
throw new Error(`Token request failed. ${await response.text()}`)
}
const { access_token, refresh_token, expires_in } = await response.json();

tokenStore.set(configItem.resource_server, access_token);
refreshTokenStore.set(configItem.resource_server, refresh_token);
tokenExpirationStore.set(configItem.resource_server, {
expires_in,
date: getTimestampInSeconds(),
});
sendMessage({ type: "accessTokenStored" });
} catch (e) {
sendMessage({ type: "accessTokenError" });
}
}

// This function intercepts all requests, but only handles those directed to the protected resource URLs.
// Particularly, it adds the Authorization header to the protected resource requests, a Bearer Token,
// and asks for a new token if the current one has expired before sending the request.
async function attachBearerToken(request, _clientId) {
const { origin } = new URL(request.url);
const { access_token, refresh_token, expires_in } = await response.json()

const configItem = configStore.get(origin);
if (!configItem || configItem.token_endpoint === request.url) {
return request;
const tx = db.transaction('token_store', 'readwrite')
await Promise.all([
tx.store.put(access_token, 'access_token'),
tx.store.put(refresh_token, 'refresh_token'),
tx.store.put({ expires_in, date: currentTime() }, 'expiry'),
tx.done
])
sendMessage({ type: 'accessTokenStored' })
} catch (e) {
console.error(e)
sendMessage({ type: 'accessTokenError' })
}
}

if (tokenStore.get(configItem.resource_server)) {
const { expires_in, date } = tokenExpirationStore.get(
configItem.resource_server
);

if (getTimestampInSeconds() - date > expires_in) {
try {
const response = await refreshToken(configItem);
await handleTokenResponse(response, configItem);
} catch (e) {
console.err(
"Something went wrong while trying to refetch the access token:",
e
);
// Register the plugin handler to handle token responses for valid endpoints
registerRoute(new Route(
({ request }) => request.url.includes('/oauth/token'),
new NetworkOnly({
plugins: [
{
fetchDidSucceed: async ({ request, response }) => {
try {
const db = await store()
const config = await db.get('config', request.url)

if (config && config.token_endpoint === request.url) {
await handleTokenResponse(response)
}
return response
} catch (e) {
console.error(e)
}
}
}
}
]
}), 'POST'))

const headers = createHeaders(
request.headers,
tokenStore.get(configItem.resource_server)
);
/**
* Clear out the current token store
*/
async function clearToken() {
try {
const db = await store()
await db.clear('token_store')

return new Request(request, { headers });
} else {
return request;
sendMessage({ type: 'accessTokenCleared' })
} catch (e) {
sendMessage({ type: 'clearTokenError' })
}
}

function isTokenEndpoint(url) {
for (const [_, value] of configStore) {
if (value.token_endpoint === url) {
return value;
}
}
/**
* Send a message to all clients registered for this Service Worker
* @param {object} message - Message object to send.
*/
function sendMessage(message) {
self.clients.matchAll().then((clients) => {
clients.forEach((client) => {
client.postMessage(message)
})
})
}

// This function intercepts all responses, but only handles the ones from the token endpoint.
// It stores the access token, refresh token, and expiration date in the corresponding Maps in memory,
// and returns a new Response to the client without the body to avoid exposing the access token.
async function storeBearerToken(response) {
const url = response.url;
const configItem = isTokenEndpoint(url);
// Create an event listener to handle all messages (counterpart to the above)
self.addEventListener('message', async (event) => {
const type = event.data.type
const db = await store()

if (!configItem) {
return response;
switch (type) {
case 'storeConfig':
await db.put('config', event.data.config, event.data.config.token_endpoint)
break
case 'clearToken':
await clearToken()
break
default:
console.error('type:', type, 'not handled')
}
})

/**
* Intercept all fetch events to attempt to attach our token by querying all
* configs and finding one with a resource server the same as the origin of our
* request
*
* Handles token expiry and will retry requests that 401 once to attempt to get
* a new token first
*/
self.addEventListener('fetch', (event) => {
event.respondWith((async () => {
const request = event.request

try {
const db = await store()
let cursor = await db.transaction('config').store.openCursor()

while (cursor) {
if (request.url.startsWith(cursor.value.resource_server)) {
const accessToken = await db.get('token_store', 'access_token')
if (accessToken) {
const { date, expires_in } = await db.get('token_store', 'expiry')

if (currentTime() - date > expires_in) {
const response = await refreshToken(cursor.value.token_endpoint)
await handleTokenResponse(response)
}

const headers = createHeaders(request.headers, await db.get('token_store', 'access_token'))
const retryResponse = await fetch(new Request(request, { headers }))

if (retryResponse.status === 401) {
await handleTokenResponse(await refreshToken(cursor.value.token_endpoint))
const headers = createHeaders(request.headers, await db.get('token_store', 'access_token'))
return await fetch(new Request(request, { headers }))
}
return retryResponse
} else {
await handleTokenResponse(await refreshToken(cursor.value.token_endpoint))
const headers = createHeaders(request.headers, await db.get('token_store', 'access_token'))
return await fetch(new Request(request, { headers }))
}
}
cursor = await cursor.continue()
}
} catch (error) {
console.warn('Something went wrong trying to refresh token')
console.warn(error)
}

await handleTokenResponse(response, configItem);

return new Response({
headers: response.headers,
status: response.status,
statusText: response.statusText,
});
}

async function fetchWithBearerToken({ request, clientId }) {
const newRequest =
request instanceof Request ? request : new Request(request);
const attachBearerTokenFn = await attachBearerToken(newRequest, clientId);
return fetch(attachBearerTokenFn).then(storeBearerToken);
}

addEventListener("fetch", (event) => {
event.respondWith(fetchWithBearerToken(event));
});
return await fetch(request)
})())
})