UNPKG

@jbrowse/plugin-authentication

Version:

JBrowse 2 Authentication

288 lines (287 loc) 11.7 kB
import { ConfigurationReference, getConf } from '@jbrowse/core/configuration'; import { InternetAccount } from '@jbrowse/core/pluggableElementTypes/models'; import { isElectron } from '@jbrowse/core/util'; import { types } from 'mobx-state-tree'; import { fixup, generateChallenge, processError, processTokenResponse, } from './util'; import { getResponseError } from '../util'; function encode(uint8array) { const output = []; for (let i = 0, length = uint8array.length; i < length; i++) { output.push(String.fromCharCode(uint8array[i])); } return btoa(output.join('')); } const stateModelFactory = (configSchema) => { return InternetAccount.named('OAuthInternetAccount') .props({ type: types.literal('OAuthInternetAccount'), configuration: ConfigurationReference(configSchema), }) .views(() => { let codeVerifier = undefined; return { get codeVerifierPKCE() { if (!codeVerifier) { const array = new Uint8Array(32); globalThis.crypto.getRandomValues(array); codeVerifier = fixup(encode(array)); } return codeVerifier; }, }; }) .views(self => ({ get authEndpoint() { return getConf(self, 'authEndpoint'); }, get tokenEndpoint() { return getConf(self, 'tokenEndpoint'); }, get needsPKCE() { return getConf(self, 'needsPKCE'); }, get clientId() { return getConf(self, 'clientId'); }, get scopes() { return getConf(self, 'scopes'); }, state() { return getConf(self, 'state'); }, get responseType() { return getConf(self, 'responseType'); }, get refreshTokenKey() { return `${self.internetAccountId}-refreshToken`; }, })) .actions(self => ({ storeRefreshToken(refreshToken) { localStorage.setItem(self.refreshTokenKey, refreshToken); }, removeRefreshToken() { localStorage.removeItem(self.refreshTokenKey); }, retrieveRefreshToken() { return localStorage.getItem(self.refreshTokenKey); }, async exchangeAuthorizationForAccessToken(token, redirectUri) { const params = new URLSearchParams(Object.entries({ code: token, grant_type: 'authorization_code', client_id: self.clientId, redirect_uri: redirectUri, ...(self.needsPKCE ? { code_verifier: self.codeVerifierPKCE } : {}), })); const response = await fetch(self.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString(), }); if (!response.ok) { throw new Error(await getResponseError({ response, reason: 'Failed to obtain token', })); } const data = await response.json(); return processTokenResponse(data, token => { this.storeRefreshToken(token); }); }, async exchangeRefreshForAccessToken(refreshToken) { const response = await fetch(self.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(Object.entries({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: self.clientId, })).toString(), }); if (!response.ok) { self.removeToken(); const text = await response.text(); throw new Error(await getResponseError({ response, statusText: processError(text, () => { this.removeRefreshToken(); }), })); } const data = await response.json(); return processTokenResponse(data, token => { this.storeRefreshToken(token); }); }, })) .actions(self => { let listener; let exchangedTokenPromise = undefined; return { addMessageChannel(resolve, reject) { listener = event => { this.finishOAuthWindow(event, resolve, reject); }; window.addEventListener('message', listener); }, deleteMessageChannel() { window.removeEventListener('message', listener); }, async finishOAuthWindow(event, resolve, reject) { if (event.data.name !== `JBrowseAuthWindow-${self.internetAccountId}`) { this.deleteMessageChannel(); return; } const redirectUriWithInfo = event.data.redirectUri; const fixedQueryString = redirectUriWithInfo.replace('#', '?'); const redirectUrl = new URL(fixedQueryString); const queryStringSearch = redirectUrl.search; const urlParams = new URLSearchParams(queryStringSearch); if (urlParams.has('access_token')) { const token = urlParams.get('access_token'); if (!token) { reject(new Error('Error with token endpoint')); return; } self.storeToken(token); resolve(token); return; } if (urlParams.has('code')) { const code = urlParams.get('code'); if (!code) { reject(new Error('Error with authorization endpoint')); return; } try { const token = await self.exchangeAuthorizationForAccessToken(code, redirectUrl.origin + redirectUrl.pathname); self.storeToken(token); resolve(token); return; } catch (e) { if (e instanceof Error) { reject(e); } else { reject(new Error(String(e))); } return; } } if (redirectUriWithInfo.includes('access_denied')) { reject(new Error('OAuth flow was cancelled')); return; } if (redirectUriWithInfo.includes('error')) { reject(new Error(`OAuth flow error: ${queryStringSearch}`)); return; } this.deleteMessageChannel(); }, async useEndpointForAuthorization(resolve, reject) { const redirectUri = isElectron ? 'http://localhost/auth' : window.location.origin + window.location.pathname; const data = { client_id: self.clientId, redirect_uri: redirectUri, response_type: self.responseType, token_access_type: 'offline', }; if (self.state()) { data.state = self.state(); } if (self.scopes) { data.scope = self.scopes; } if (self.needsPKCE) { data.code_challenge = await generateChallenge(self.codeVerifierPKCE); data.code_challenge_method = 'S256'; } const params = new URLSearchParams(Object.entries(data)); const url = new URL(self.authEndpoint); url.search = params.toString(); const eventName = `JBrowseAuthWindow-${self.internetAccountId}`; if (isElectron) { const { ipcRenderer } = window.require('electron'); const redirectUri = await ipcRenderer.invoke('openAuthWindow', { internetAccountId: self.internetAccountId, data, url: url.toString(), }); const eventFromDesktop = new MessageEvent('message', { data: { name: eventName, redirectUri: redirectUri }, }); this.finishOAuthWindow(eventFromDesktop, resolve, reject); } else { window.open(url, eventName, 'width=500,height=600,left=0,top=0'); } }, async getTokenFromUser(resolve, reject) { const refreshToken = self.retrieveRefreshToken(); let doUserFlow = true; if (refreshToken) { try { const token = await self.exchangeRefreshForAccessToken(refreshToken); resolve(token); doUserFlow = false; } catch (e) { console.error(e); self.removeRefreshToken(); } } if (doUserFlow) { this.addMessageChannel(resolve, reject); this.useEndpointForAuthorization(resolve, reject); } }, async validateToken(token, location) { const newInit = self.addAuthHeaderToInit({ method: 'HEAD' }, token); const response = await fetch(location.uri, newInit); if (!response.ok) { self.removeToken(); const refreshToken = self.retrieveRefreshToken(); if (refreshToken) { try { if (!exchangedTokenPromise) { exchangedTokenPromise = self.exchangeRefreshForAccessToken(refreshToken); } const newToken = await exchangedTokenPromise; exchangedTokenPromise = undefined; return newToken; } catch (err) { console.error('Token could not be refreshed', err); } } throw new Error(await getResponseError({ response, reason: 'Error validating token', })); } return token; }, }; }) .actions(self => { const superGetFetcher = self.getFetcher; return { getFetcher(loc) { const fetcher = superGetFetcher(loc); return async (input, init) => { if (loc) { await self.validateToken(await self.getToken(loc), loc); } return fetcher(input, init); }; }, }; }); }; export default stateModelFactory;