@upbond/auth-spa-js
Version:
Auth SDK for Single Page Applications using Authorization Code Grant Flow with PKCE
401 lines (346 loc) • 10.9 kB
text/typescript
import fetch from 'unfetch';
import {
AuthenticationResult,
PopupConfigOptions,
TokenEndpointOptions
} from './global';
import {
DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS,
DEFAULT_SILENT_TOKEN_RETRY_COUNT,
DEFAULT_FETCH_TIMEOUT_MS,
CLEANUP_IFRAME_TIMEOUT_IN_SECONDS
} from './constants';
const TIMEOUT_ERROR = { error: 'timeout', error_description: 'Timeout' };
export const createAbortController = () => new AbortController();
export const parseQueryResult = (queryString: string) => {
if (queryString.indexOf('#') > -1) {
queryString = queryString.substr(0, queryString.indexOf('#'));
}
let queryParams = queryString.split('&');
let parsedQuery: any = {};
queryParams.forEach(qp => {
let [key, val] = qp.split('=');
parsedQuery[key] = decodeURIComponent(val);
});
return <AuthenticationResult>{
...parsedQuery,
expires_in: parseInt(parsedQuery.expires_in)
};
};
export const runIframe = (
authorizeUrl: string,
eventOrigin: string,
timeoutInSeconds: number = DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS
) => {
return new Promise<AuthenticationResult>((res, rej) => {
var iframe = window.document.createElement('iframe');
iframe.setAttribute('width', '0');
iframe.setAttribute('height', '0');
iframe.style.display = 'none';
const removeIframe = () => {
if (window.document.body.contains(iframe)) {
window.document.body.removeChild(iframe);
}
};
const timeoutSetTimeoutId = setTimeout(() => {
rej(TIMEOUT_ERROR);
removeIframe();
}, timeoutInSeconds * 1000);
const iframeEventHandler = function (e: MessageEvent) {
if (e.origin != eventOrigin) return;
if (!e.data || e.data.type !== 'authorization_response') return;
const eventSource = e.source;
if (eventSource) {
(<any>eventSource).close();
}
e.data.response.error ? rej(e.data.response) : res(e.data.response);
clearTimeout(timeoutSetTimeoutId);
window.removeEventListener('message', iframeEventHandler, false);
// Delay the removal of the iframe to prevent hanging loading status
// in Chrome: https://github.com/auth/auth-spa-js/issues/240
setTimeout(removeIframe, CLEANUP_IFRAME_TIMEOUT_IN_SECONDS * 1000);
};
window.addEventListener('message', iframeEventHandler, false);
window.document.body.appendChild(iframe);
iframe.setAttribute('src', authorizeUrl);
});
};
const openPopup = url => {
const width = 400;
const height = 600;
const left = window.screenX + (window.innerWidth - width) / 2;
const top = window.screenY + (window.innerHeight - height) / 2;
return window.open(
url,
'auth:authorize:popup',
`left=${left},top=${top},width=${width},height=${height},resizable,scrollbars=yes,status=1`
);
};
export const runPopup = (authorizeUrl: string, config: PopupConfigOptions) => {
let popup = config.popup;
if (popup) {
popup.location.href = authorizeUrl;
} else {
popup = openPopup(authorizeUrl);
}
if (!popup) {
throw new Error('Could not open popup');
}
return new Promise<AuthenticationResult>((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject({ ...TIMEOUT_ERROR, popup });
}, (config.timeoutInSeconds || DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS) * 1000);
window.addEventListener('message', e => {
if (!e.data || e.data.type !== 'authorization_response') {
return;
}
clearTimeout(timeoutId);
popup.close();
if (e.data.response.error) {
return reject(e.data.response);
}
resolve(e.data.response);
});
});
};
export const createRandomString = () => {
const charset =
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_~.';
let random = '';
const randomValues = Array.from(
getCrypto().getRandomValues(new Uint8Array(43))
);
randomValues.forEach(v => (random += charset[v % charset.length]));
return random;
};
export const encode = (value: string) => btoa(value);
export const decode = (value: string) => atob(value);
export const createQueryParams = (params: any) => {
return Object.keys(params)
.filter(k => typeof params[k] !== 'undefined')
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
.join('&');
};
export const sha256 = async (s: string) => {
const digestOp = getCryptoSubtle().digest(
{ name: 'SHA-256' },
new TextEncoder().encode(s)
);
// msCrypto (IE11) uses the old spec, which is not Promise based
// https://msdn.microsoft.com/en-us/expression/dn904640(v=vs.71)
// Instead of returning a promise, it returns a CryptoOperation
// with a result property in it.
// As a result, the various events need to be handled in the event that we're
// working in IE11 (hence the msCrypto check). These events just call resolve
// or reject depending on their intention.
if ((<any>window).msCrypto) {
return new Promise((res, rej) => {
digestOp.oncomplete = e => {
res(e.target.result);
};
digestOp.onerror = (e: ErrorEvent) => {
rej(e.error);
};
digestOp.onabort = () => {
rej('The digest operation was aborted');
};
});
}
return await digestOp;
};
const urlEncodeB64 = (input: string) => {
const b64Chars = { '+': '-', '/': '_', '=': '' };
return input.replace(/[\+\/=]/g, (m: string) => b64Chars[m]);
};
// https://stackoverflow.com/questions/30106476/
const decodeB64 = input =>
decodeURIComponent(
atob(input)
.split('')
.map(c => {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
})
.join('')
);
export const urlDecodeB64 = (input: string) =>
decodeB64(input.replace(/_/g, '/').replace(/-/g, '+'));
export const bufferToBase64UrlEncoded = input => {
const ie11SafeInput = new Uint8Array(input);
return urlEncodeB64(
window.btoa(String.fromCharCode(...Array.from(ie11SafeInput)))
);
};
const sendMessage = (message, to) =>
new Promise(function (resolve, reject) {
const messageChannel = new MessageChannel();
messageChannel.port1.onmessage = function (event) {
// Only for fetch errors, as these get retried
if (event.data.error) {
reject(new Error(event.data.error));
} else {
resolve(event.data);
}
};
to.postMessage(message, [messageChannel.port2]);
});
const switchFetch = async (url, opts, timeout, worker) => {
if (worker) {
// AbortSignal is not serializable, need to implement in the Web Worker
delete opts.signal;
return sendMessage({ url, timeout, ...opts }, worker);
} else {
const response = await fetch(url, opts);
return {
ok: response.ok,
json: await response.json()
};
}
};
export const fetchWithTimeout = (
url,
options,
worker,
timeout = DEFAULT_FETCH_TIMEOUT_MS
) => {
const controller = createAbortController();
const signal = controller.signal;
const fetchOptions = {
...options,
signal
};
let timeoutId;
// The promise will resolve with one of these two promises (the fetch or the timeout), whichever completes first.
return Promise.race([
switchFetch(url, fetchOptions, timeout, worker),
new Promise((_, reject) => {
timeoutId = setTimeout(() => {
controller.abort();
reject(new Error("Timeout when executing 'fetch'"));
}, timeout);
})
]).finally(() => {
clearTimeout(timeoutId);
});
};
const getJSON = async (url, timeout, options, worker) => {
let fetchError, response;
for (let i = 0; i < DEFAULT_SILENT_TOKEN_RETRY_COUNT; i++) {
try {
response = await fetchWithTimeout(url, options, worker, timeout);
fetchError = null;
break;
} catch (e) {
// Fetch only fails in the case of a network issue, so should be
// retried here. Failure status (4xx, 5xx, etc) return a resolved Promise
// with the failure in the body.
// https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
fetchError = e;
}
}
if (fetchError) {
throw fetchError;
}
const {
json: { error, error_description, ...success },
ok
} = response;
if (!ok) {
const errorMessage =
error_description || `HTTP error. Unable to fetch ${url}`;
const e = <any>new Error(errorMessage);
e.error = error || 'request_error';
e.error_description = errorMessage;
throw e;
}
return success;
};
export const getUser = async (accessToken, baseUrl, worker) => {
let fetchError, response;
try {
response = await fetchWithTimeout(
`${baseUrl}/authenticate/user`,
{
method: 'GET',
headers: {
'Content-type': 'application/json',
Authorization: `Bearer ${accessToken}`
}
},
worker
);
fetchError = null;
} catch (e) {
// Fetch only fails in the case of a network issue, so should be
// retried here. Failure status (4xx, 5xx, etc) return a resolved Promise
// with the failure in the body.
// https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
fetchError = e;
}
const {
json: { error, error_description, ...success },
ok
} = response;
if (!ok) {
const errorMessage =
error_description || `HTTP error. Unable to fetch ${baseUrl}/authenticate/user`;
const e = <any>new Error(errorMessage);
e.error = error || 'request_error';
e.error_description = errorMessage;
throw e;
}
return success;
};
// await getJSON(
// `${baseUrl}/authenticate/user`,
// 0,
// {
// method: 'GET',
// headers: {
// 'Content-type': 'application/json',
// 'Authorization': `Bearer ${accessToken}`
// }
// },
// worker
// );
export const oauthToken = async (
{ baseUrl, timeout, ...options }: TokenEndpointOptions,
worker
) =>
await getJSON(
`${baseUrl}/authenticate/oauth/token`,
timeout,
{
method: 'POST',
body: JSON.stringify({
redirect_uri: window.location.origin,
...options
}),
headers: {
'Content-type': 'application/json'
}
},
worker
);
export const getCrypto = () => {
//ie 11.x uses msCrypto
return <Crypto>(window.crypto || (<any>window).msCrypto);
};
export const getCryptoSubtle = () => {
const crypto = getCrypto();
//safari 10.x uses webkitSubtle
return crypto.subtle || (<any>crypto).webkitSubtle;
};
export const validateCrypto = () => {
if (!getCrypto()) {
throw new Error(
'For security reasons, `window.crypto` is required to run `auth-spa-js`.'
);
}
if (typeof getCryptoSubtle() === 'undefined') {
throw new Error(`
auth-spa-js must run on a secure origin.
See https://github.com/auth/auth-spa-js/blob/master/FAQ.md#why-do-i-get-auth-spa-js-must-run-on-a-secure-origin
for more information.
`);
}
};