UNPKG

@atproto/oauth-client-browser

Version:

ATPROTO OAuth client for the browser (relies on WebCrypto & Indexed DB)

358 lines 15.5 kB
import { OAuthCallbackError, OAuthClient, } from '@atproto/oauth-client'; import { assertOAuthDiscoverableClientId, atprotoLoopbackClientMetadata, isOAuthClientIdLoopback, } from '@atproto/oauth-types'; import { BrowserOAuthDatabase } from './browser-oauth-database.js'; import { BrowserRuntimeImplementation } from './browser-runtime-implementation.js'; import { LoginContinuedInParentWindowError } from './errors.js'; import { buildLoopbackClientId } from './util.js'; const NAMESPACE = `@@atproto/oauth-client-browser`; //- Popup channel const POPUP_CHANNEL_NAME = `${NAMESPACE}(popup-channel)`; const POPUP_STATE_PREFIX = `${NAMESPACE}(popup-state):`; const syncChannel = new BroadcastChannel(`${NAMESPACE}(synchronization-channel:2)`); const runtimeImplementation = new BrowserRuntimeImplementation(); export class BrowserOAuthClient extends OAuthClient { static async load({ clientId, ...options }) { if (clientId.startsWith('http:')) { const clientMetadata = atprotoLoopbackClientMetadata(clientId); return new BrowserOAuthClient({ clientMetadata, ...options }); } else if (clientId.startsWith('https:')) { assertOAuthDiscoverableClientId(clientId); const clientMetadata = await OAuthClient.fetchMetadata({ clientId, ...options, }); return new BrowserOAuthClient({ ...options, clientMetadata }); } else { throw new TypeError(`Invalid client id: ${clientId}`); } } constructor({ clientMetadata = atprotoLoopbackClientMetadata(buildLoopbackClientId(window.location)), // "fragment" is a safer default as the query params will not be sent to the server responseMode = 'fragment', ...options }) { if (!globalThis.crypto?.subtle) { throw new Error('WebCrypto API is required'); } if (!['query', 'fragment'].includes(responseMode)) { // Make sure "form_post" is not used as it is not supported in the browser throw new TypeError(`Invalid response mode: ${responseMode}`); } const database = new BrowserOAuthDatabase(); super({ ...options, clientMetadata, responseMode, keyset: undefined, runtimeImplementation, sessionStore: database.getSessionStore(), stateStore: database.getStateStore(), didCache: database.getDidCache(), handleCache: database.getHandleCache(), dpopNonceCache: database.getDpopNonceCache(), authorizationServerMetadataCache: database.getAuthorizationServerMetadataCache(), protectedResourceMetadataCache: database.getProtectedResourceMetadataCache(), onSessionDeleted: async (sub, cause) => { if (localStorage.getItem(`${NAMESPACE}(sub)`) === sub) { localStorage.removeItem(`${NAMESPACE}(sub)`); } syncChannel.postMessage({ name: 'onSessionDeleted', args: [sub, cause], }); return options.onSessionDeleted?.call(null, sub, cause); }, onSessionUpdated: async (sub, session) => { syncChannel.postMessage({ name: 'onSessionUpdated', args: [sub, session], }); return options.onSessionUpdated?.call(null, sub, session); }, }); this.ac = new AbortController(); this.database = database; const { signal } = this.ac; // Trigger hooks when an event is emitted in another tab syncChannel.addEventListener('message', (event) => { if (event.source === window) return; const { name, args } = event.data; const hook = options[name]; // @ts-expect-error TS has a hard time matching the args with the hook void hook?.(...args); }, // Remove the listener when the client is disposed { signal }); } /** * This method will automatically restore any existing session, or attempt to * process login callback if the URL contains oauth parameters. * * Use {@link BrowserOAuthClient.initCallback} instead of this method if you * want to force a login callback. This can be esp. useful if you are using * this lib from a framework that has some kind of URL manipulation (like a * client side router). * * Use {@link BrowserOAuthClient.initRestore} instead of this method if you * want to only restore existing sessions, and bypass the automatic processing * of login callbacks. */ async init(refresh) { // If the URL currently contains oauth query parameters ("state" + "code" or // "state" + "error"), let's automatically process them. const params = this.readCallbackParams(); if (params) { const redirectUri = this.findRedirectUrl(); if (redirectUri) return this.initCallback(params, redirectUri); } return this.initRestore(refresh); } async initRestore(refresh) { // @NOTE Fixing the location should not be needed from callback endpoints // since callback endpoint are required to use IP based URLs (for localhost) await fixLocation(this.clientMetadata); const sub = localStorage.getItem(`${NAMESPACE}(sub)`); if (sub) { try { const session = await this.restore(sub, refresh); return { session }; } catch (err) { localStorage.removeItem(`${NAMESPACE}(sub)`); throw err; } } } async restore(sub, refresh) { const session = await super.restore(sub, refresh); localStorage.setItem(`${NAMESPACE}(sub)`, session.sub); return session; } async revoke(sub) { localStorage.removeItem(`${NAMESPACE}(sub)`); return super.revoke(sub); } async signIn(input, options) { if (options?.display === 'popup') { return this.signInPopup(input, options); } else { return this.signInRedirect(input, options); } } async signInRedirect(input, options) { const url = await this.authorize(input, options); window.location.href = url.href; // back-forward cache return new Promise((resolve, reject) => { setTimeout((err) => { // Take the opportunity to proactively cancel the pending request this.abortRequest(url).then(() => reject(err), (reason) => reject(new AggregateError([err, reason]))); }, 5e3, new Error('User navigated back')); }); } async signInPopup(input, options) { const popupTarget = options?.popupName ?? '_blank'; // Open new window asap to prevent popup busting by browsers const popupFeatures = options?.popupFeatures ?? 'width=600,height=600,menubar=no,toolbar=no'; let popup = window.open('about:blank', popupTarget, popupFeatures); const stateKey = `${Math.random().toString(36).slice(2)}`; const url = await this.authorize(input, { ...options, state: `${POPUP_STATE_PREFIX}${stateKey}`, display: options?.display ?? 'popup', }); options?.signal?.throwIfAborted(); if (popup) { popup.window.location.href = url.href; } else { popup = window.open(url.href, popupTarget, popupFeatures); } popup?.focus(); return new Promise((resolve, reject) => { const popupChannel = new BroadcastChannel(POPUP_CHANNEL_NAME); const cleanup = () => { clearTimeout(timeout); popupChannel.removeEventListener('message', onMessage); popupChannel.close(); options?.signal?.removeEventListener('abort', cancel); popup?.close(); }; const cancel = () => { // @TODO Store fact that the request was cancelled, allowing any // callback (e.g. in the popup) to revoke the session or credentials. reject(new Error(options?.signal?.aborted ? 'Aborted' : 'Timeout')); cleanup(); }; options?.signal?.addEventListener('abort', cancel); const timeout = setTimeout(cancel, 5 * 60e3); const onMessage = async ({ data }) => { if (data.key !== stateKey) return; if (!('result' in data)) return; // Send acknowledgment to popup window popupChannel.postMessage({ key: stateKey, ack: true }); cleanup(); const { result } = data; if (result.status === 'fulfilled') { const sub = result.value; try { options?.signal?.throwIfAborted(); resolve(await this.restore(sub, false)); } catch (err) { reject(err); void this.revoke(sub); } } else { const { message, params } = result.reason; reject(new OAuthCallbackError(new URLSearchParams(params), message)); } }; popupChannel.addEventListener('message', onMessage); }); } findRedirectUrl() { for (const uri of this.clientMetadata.redirect_uris) { const url = new URL(uri); if (location.origin === url.origin && location.pathname === url.pathname) { return uri; } } return undefined; } readCallbackParams() { const params = this.responseMode === 'fragment' ? new URLSearchParams(location.hash.slice(1)) : new URLSearchParams(location.search); // Only if the current URL contains a valid oauth response params if (!params.has('state') || !(params.has('code') || params.has('error'))) { return null; } return params; } async initCallback(params = this.readCallbackParams(), redirectUri = this.findRedirectUrl()) { if (!params) { throw new TypeError('No OAuth callback parameters found in the URL'); } // Replace the current history entry without the params (this will prevent // the following code to run again if the user refreshes the page) if (this.responseMode === 'fragment') { history.replaceState(null, '', location.pathname + location.search); } else if (this.responseMode === 'query') { history.replaceState(null, '', location.pathname); } // Utility function to send the result of the popup to the parent window const sendPopupResult = (message) => { const popupChannel = new BroadcastChannel(POPUP_CHANNEL_NAME); return new Promise((resolve) => { const cleanup = (result) => { clearTimeout(timer); popupChannel.removeEventListener('message', onMessage); popupChannel.close(); resolve(result); }; const onMessage = ({ data }) => { if ('ack' in data && message.key === data.key) cleanup(true); }; popupChannel.addEventListener('message', onMessage); popupChannel.postMessage(message); // Receiving of "ack" should be very fast, giving it 500 ms anyway const timer = setTimeout(cleanup, 500, false); }); }; return this.callback(params, { redirect_uri: redirectUri }) .then(async (result) => { if (result.state?.startsWith(POPUP_STATE_PREFIX)) { const receivedByParent = await sendPopupResult({ key: result.state.slice(POPUP_STATE_PREFIX.length), result: { status: 'fulfilled', value: result.session.sub, }, }); // Revoke the credentials if the parent window was closed if (!receivedByParent) await result.session.signOut(); throw new LoginContinuedInParentWindowError(); // signInPopup } localStorage.setItem(`${NAMESPACE}(sub)`, result.session.sub); return result; }) .catch(async (err) => { if (err instanceof OAuthCallbackError && err.state?.startsWith(POPUP_STATE_PREFIX)) { await sendPopupResult({ key: err.state.slice(POPUP_STATE_PREFIX.length), result: { status: 'rejected', reason: { message: err.message, params: Array.from(err.params.entries()), }, }, }); throw new LoginContinuedInParentWindowError(); // signInPopup } // Most probable cause at this point is that the "state" parameter is // invalid. throw err; }) .catch((err) => { if (err instanceof LoginContinuedInParentWindowError) { // parent will also try to close the popup window.close(); } throw err; }); } async [Symbol.asyncDispose]() { try { this.ac.abort(); } finally { await this.database[Symbol.asyncDispose](); } } async dispose() { await this[Symbol.asyncDispose](); } } /** * Since "localhost" is often used either in IP mode or in hostname mode, * and because the redirect uris must use the IP mode, we need to make sure * that the current location url is not using "localhost". * * This is required for the IndexedDB to work properly. Indeed, the IndexedDB * is shared by origin, so we must ensure to be on the same origin as the * redirect uris. */ function fixLocation(clientMetadata) { if (!isOAuthClientIdLoopback(clientMetadata.client_id)) return; if (window.location.hostname !== 'localhost') return; const locationUrl = new URL(window.location.href); for (const uri of clientMetadata.redirect_uris) { const url = new URL(uri); if ((url.hostname === '127.0.0.1' || url.hostname === '[::1]') && (!url.port || url.port === locationUrl.port) && url.protocol === locationUrl.protocol && url.pathname === locationUrl.pathname) { url.port = locationUrl.port; window.location.href = url.href; // Prevent init() on the wrong origin throw new Error('Redirecting to loopback IP...'); } } throw new Error(`Please use the loopback IP address instead of ${locationUrl}`); } //# sourceMappingURL=browser-oauth-client.js.map