@jbrowse/plugin-authentication
Version:
JBrowse 2 Authentication
288 lines (287 loc) • 11.7 kB
JavaScript
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;