@atproto/oauth-client-browser
Version:
ATPROTO OAuth client for the browser (relies on WebCrypto & Indexed DB)
358 lines • 15.5 kB
JavaScript
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