@entrustcorp/idaas-auth-js
Version:
IDaaS Authentication SDK for SPA applications
1,242 lines (1,241 loc) • 90.4 kB
JavaScript
import { createRemoteJWKSet, decodeJwt, decodeProtectedHeader, jwtVerify } from "jose";
const DEFAULT_POPUP_TIMEOUT_SECONDS = 300;
const openPopup = (popupUrl)=>{
const width = 500;
const height = 700;
const left = window.screenX + (window.innerWidth - width) / 2;
const top = window.screenY + (window.innerHeight - height) / 2;
const popup = window.open(popupUrl, "idaas:authorize", `popup,left=${left},top=${top},width=${width},height=${height}`);
if (!popup) throw new Error("Unable to open popup, blocked by browser");
return popup;
};
const listenToAuthorizePopup = (popup, url)=>{
const expectedOrigin = new URL(url).origin;
return new Promise((resolve, reject)=>{
const popupListenerAbortController = new AbortController();
const popupWebMessageEventHandler = (event)=>{
const hasOriginAndData = event.origin === expectedOrigin && event.data;
const isAuthorizeEvent = hasOriginAndData && "authorization_response" === event.data.type;
if (!isAuthorizeEvent) return;
cleanUpPopup();
const response = event.data.response;
if (response.error) reject(new Error(response.error));
resolve(response);
};
const pollPopupInterval = setInterval(()=>{
if (popup.closed) {
cleanUpPopup();
reject(new Error("Authentication was cancelled by the user"));
}
}, 1000);
const popupTimeout = setTimeout(()=>{
cleanUpPopup();
reject(new Error("User took too long to authenticate"));
}, 1000 * DEFAULT_POPUP_TIMEOUT_SECONDS);
const cleanUpPopup = ()=>{
clearInterval(pollPopupInterval);
clearTimeout(popupTimeout);
popup.close();
popupListenerAbortController.abort();
};
window.addEventListener("message", popupWebMessageEventHandler, {
signal: popupListenerAbortController.signal
});
});
};
const browserSupportsPasskey = async ()=>!!window.PublicKeyCredential;
class AuthClient {
#rbaClient;
constructor(rbaClient){
this.#rbaClient = rbaClient;
}
async #importOnfidoSdk() {
try {
const { Onfido } = await import("onfido-sdk-ui");
return Onfido;
} catch (error) {
console.error("Failed to import onfido-sdk-ui. Ensure the package is installed as it is required for face authentication.", error);
throw error;
}
}
async password(userId, password) {
await this.#rbaClient.requestChallenge({
userId,
strict: true,
preferredAuthenticationMethod: "PASSWORD"
});
const authResult = await this.#rbaClient.submitChallenge({
response: password
});
return authResult;
}
async softToken(userId, { mutualChallenge, push } = {}) {
if (push && !mutualChallenge) {
await this.#rbaClient.requestChallenge({
userId,
strict: true,
preferredAuthenticationMethod: "TOKENPUSH"
});
return await this.#rbaClient.poll();
}
if (push && mutualChallenge) return await this.#rbaClient.requestChallenge({
userId,
strict: true,
preferredAuthenticationMethod: "TOKENPUSH",
softTokenPushOptions: {
mutualChallenge: true
}
});
return await this.#rbaClient.requestChallenge({
userId,
strict: true,
preferredAuthenticationMethod: "TOKEN"
});
}
async grid(userId) {
return await this.#rbaClient.requestChallenge({
userId,
strict: true,
preferredAuthenticationMethod: "GRID"
});
}
async passkey(userId) {
const browserSupported = await browserSupportsPasskey();
if (!browserSupported) throw new Error("This browser does not support passkey");
const authenticationRequestParams = {
strict: true,
preferredAuthenticationMethod: userId ? "FIDO" : "PASSKEY",
userId
};
const response = await this.#rbaClient.requestChallenge(authenticationRequestParams);
if (response.passkeyChallenge) {
const publicKeyCredential = await window.navigator.credentials.get({
publicKey: response.passkeyChallenge
});
if (publicKeyCredential && publicKeyCredential instanceof PublicKeyCredential) return await this.#rbaClient.submitChallenge({
passkeyResponse: publicKeyCredential
});
throw new Error("No credential was returned.");
}
throw new Error("No publicKeyCredentialRequestOptions returned for passkey authentication.");
}
async kba(userId) {
return await this.#rbaClient.requestChallenge({
userId,
strict: true,
preferredAuthenticationMethod: "KBA"
});
}
async tempAccessCode(userId, tempAccessCode) {
await this.#rbaClient.requestChallenge({
userId,
strict: true,
preferredAuthenticationMethod: "TEMP_ACCESS_CODE"
});
return await this.#rbaClient.submitChallenge({
response: tempAccessCode
});
}
async otp(userId, { otpDeliveryType, otpDeliveryAttribute } = {}) {
return await this.#rbaClient.requestChallenge({
userId,
strict: true,
preferredAuthenticationMethod: "OTP",
otpOptions: {
otpDeliveryAttribute,
otpDeliveryType
}
});
}
async magicLink(userId) {
await this.#rbaClient.requestChallenge({
userId,
strict: true,
preferredAuthenticationMethod: "MAGICLINK"
});
return await this.#rbaClient.poll();
}
async smartCredential(userId, { summary, pushMessageIdentifier } = {}) {
await this.#rbaClient.requestChallenge({
userId,
strict: true,
preferredAuthenticationMethod: "SMARTCREDENTIALPUSH",
smartCredentialOptions: {
summary,
pushMessageIdentifier
}
});
return await this.#rbaClient.poll();
}
async faceBiometric(userId, { mutualChallenge } = {}) {
const challengeResponse = await this.#rbaClient.requestChallenge({
userId,
strict: true,
preferredAuthenticationMethod: "FACE",
faceBiometricOptions: {
mutualChallenge: mutualChallenge
}
});
if (!challengeResponse.faceChallenge) throw new Error("Face challenge data is missing in the authentication response.");
if ("WEB" !== challengeResponse.faceChallenge.device) return mutualChallenge ? challengeResponse : await this.#rbaClient.poll();
const Onfido = await this.#importOnfidoSdk();
const authenticationResponse = await new Promise((resolve, reject)=>{
try {
const instance = Onfido.init({
token: challengeResponse.faceChallenge?.sdkToken,
workflowRunId: challengeResponse.faceChallenge?.workflowRunId,
containerId: "onfido-mount",
onComplete: async ()=>{
const authenticationPollResponse = await this.#rbaClient.poll();
resolve(authenticationPollResponse);
instance.tearDown();
},
onError: (error)=>{
reject(error);
}
});
} catch (e) {
reject(e);
}
});
return authenticationResponse;
}
async submit({ response, passkeyResponse, kbaChallengeAnswers }) {
return await this.#rbaClient.submitChallenge({
response,
passkeyResponse,
kbaChallengeAnswers
});
}
async logout() {
return await this.#rbaClient.logout();
}
async poll() {
return await this.#rbaClient.poll();
}
async cancel() {
return await this.#rbaClient.cancel();
}
}
function createSseClient({ onRequest, onSseError, onSseEvent, responseTransformer, responseValidator, sseDefaultRetryDelay, sseMaxRetryAttempts, sseMaxRetryDelay, sseSleepFn, url, ...options }) {
let lastEventId;
const sleep = sseSleepFn ?? ((ms)=>new Promise((resolve)=>setTimeout(resolve, ms)));
const createStream = async function*() {
let retryDelay = sseDefaultRetryDelay ?? 3000;
let attempt = 0;
const signal = options.signal ?? new AbortController().signal;
while(true){
if (signal.aborted) break;
attempt++;
const headers = options.headers instanceof Headers ? options.headers : new Headers(options.headers);
if (void 0 !== lastEventId) headers.set('Last-Event-ID', lastEventId);
try {
const requestInit = {
redirect: 'follow',
...options,
body: options.serializedBody,
headers,
signal
};
let request = new Request(url, requestInit);
if (onRequest) request = await onRequest(url, requestInit);
const _fetch = options.fetch ?? globalThis.fetch;
const response = await _fetch(request);
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
if (!response.body) throw new Error('No body in SSE response');
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
const abortHandler = ()=>{
try {
reader.cancel();
} catch {}
};
signal.addEventListener('abort', abortHandler);
try {
while(true){
const { done, value } = await reader.read();
if (done) break;
buffer += value;
buffer = buffer.replace(/\r\n?/g, '\n');
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks){
const lines = chunk.split('\n');
const dataLines = [];
let eventName;
for (const line of lines)if (line.startsWith('data:')) dataLines.push(line.replace(/^data:\s*/, ''));
else if (line.startsWith('event:')) eventName = line.replace(/^event:\s*/, '');
else if (line.startsWith('id:')) lastEventId = line.replace(/^id:\s*/, '');
else if (line.startsWith('retry:')) {
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10);
if (!Number.isNaN(parsed)) retryDelay = parsed;
}
let data;
let parsedJson = false;
if (dataLines.length) {
const rawData = dataLines.join('\n');
try {
data = JSON.parse(rawData);
parsedJson = true;
} catch {
data = rawData;
}
}
if (parsedJson) {
if (responseValidator) await responseValidator(data);
if (responseTransformer) data = await responseTransformer(data);
}
onSseEvent?.({
data,
event: eventName,
id: lastEventId,
retry: retryDelay
});
if (dataLines.length) yield data;
}
}
} finally{
signal.removeEventListener('abort', abortHandler);
reader.releaseLock();
}
break;
} catch (error) {
onSseError?.(error);
if (void 0 !== sseMaxRetryAttempts && attempt >= sseMaxRetryAttempts) break;
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
await sleep(backoff);
}
}
};
const stream = createStream();
return {
stream
};
}
const separatorArrayExplode = (style)=>{
switch(style){
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
const separatorArrayNoExplode = (style)=>{
switch(style){
case 'form':
return ',';
case 'pipeDelimited':
return '|';
case 'spaceDelimited':
return '%20';
default:
return ',';
}
};
const separatorObjectExplode = (style)=>{
switch(style){
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
const serializeArrayParam = ({ allowReserved, explode, name, style, value })=>{
if (!explode) {
const joinedValues = (allowReserved ? value : value.map((v)=>encodeURIComponent(v))).join(separatorArrayNoExplode(style));
switch(style){
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
case 'simple':
return joinedValues;
default:
return `${name}=${joinedValues}`;
}
}
const separator = separatorArrayExplode(style);
const joinedValues = value.map((v)=>{
if ('label' === style || 'simple' === style) return allowReserved ? v : encodeURIComponent(v);
return serializePrimitiveParam({
allowReserved,
name,
value: v
});
}).join(separator);
return 'label' === style || 'matrix' === style ? separator + joinedValues : joinedValues;
};
const serializePrimitiveParam = ({ allowReserved, name, value })=>{
if (null == value) return '';
if ('object' == typeof value) throw new Error('Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.');
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
};
const serializeObjectParam = ({ allowReserved, explode, name, style, value, valueOnly })=>{
if (value instanceof Date) return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
if ('deepObject' !== style && !explode) {
let values = [];
Object.entries(value).forEach(([key, v])=>{
values = [
...values,
key,
allowReserved ? v : encodeURIComponent(v)
];
});
const joinedValues = values.join(',');
switch(style){
case 'form':
return `${name}=${joinedValues}`;
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
default:
return joinedValues;
}
}
const separator = separatorObjectExplode(style);
const joinedValues = Object.entries(value).map(([key, v])=>serializePrimitiveParam({
allowReserved,
name: 'deepObject' === style ? `${name}[${key}]` : key,
value: v
})).join(separator);
return 'label' === style || 'matrix' === style ? separator + joinedValues : joinedValues;
};
const PATH_PARAM_RE = /\{[^{}]+\}/g;
const defaultPathSerializer = ({ path, url: _url })=>{
let url = _url;
const matches = _url.match(PATH_PARAM_RE);
if (matches) for (const match of matches){
let explode = false;
let name = match.substring(1, match.length - 1);
let style = 'simple';
if (name.endsWith('*')) {
explode = true;
name = name.substring(0, name.length - 1);
}
if (name.startsWith('.')) {
name = name.substring(1);
style = 'label';
} else if (name.startsWith(';')) {
name = name.substring(1);
style = 'matrix';
}
const value = path[name];
if (null == value) continue;
if (Array.isArray(value)) {
url = url.replace(match, serializeArrayParam({
explode,
name,
style,
value
}));
continue;
}
if ('object' == typeof value) {
url = url.replace(match, serializeObjectParam({
explode,
name,
style,
value: value,
valueOnly: true
}));
continue;
}
if ('matrix' === style) {
url = url.replace(match, `;${serializePrimitiveParam({
name,
value: value
})}`);
continue;
}
const replaceValue = encodeURIComponent('label' === style ? `.${value}` : value);
url = url.replace(match, replaceValue);
}
return url;
};
const getUrl = ({ baseUrl, path, query, querySerializer, url: _url })=>{
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
let url = (baseUrl ?? '') + pathUrl;
if (path) url = defaultPathSerializer({
path,
url
});
let search = query ? querySerializer(query) : '';
if (search.startsWith('?')) search = search.substring(1);
if (search) url += `?${search}`;
return url;
};
function getValidRequestBody(options) {
const hasBody = void 0 !== options.body;
const isSerializedBody = hasBody && options.bodySerializer;
if (isSerializedBody) {
if ('serializedBody' in options) {
const hasSerializedBody = void 0 !== options.serializedBody && '' !== options.serializedBody;
return hasSerializedBody ? options.serializedBody : null;
}
return '' !== options.body ? options.body : null;
}
if (hasBody) return options.body;
}
const getAuthToken = async (auth, callback)=>{
const token = 'function' == typeof callback ? await callback(auth) : callback;
if (!token) return;
if ('bearer' === auth.scheme) return `Bearer ${token}`;
if ('basic' === auth.scheme) return `Basic ${btoa(token)}`;
return token;
};
const jsonBodySerializer = {
bodySerializer: (body)=>JSON.stringify(body, (_key, value)=>'bigint' == typeof value ? value.toString() : value)
};
const createQuerySerializer = ({ parameters = {}, ...args } = {})=>{
const querySerializer = (queryParams)=>{
const search = [];
if (queryParams && 'object' == typeof queryParams) for(const name in queryParams){
const value = queryParams[name];
if (null == value) continue;
const options = parameters[name] || args;
if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: 'form',
value,
...options.array
});
if (serializedArray) search.push(serializedArray);
} else if ('object' == typeof value) {
const serializedObject = serializeObjectParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: 'deepObject',
value: value,
...options.object
});
if (serializedObject) search.push(serializedObject);
} else {
const serializedPrimitive = serializePrimitiveParam({
allowReserved: options.allowReserved,
name,
value: value
});
if (serializedPrimitive) search.push(serializedPrimitive);
}
}
return search.join('&');
};
return querySerializer;
};
const getParseAs = (contentType)=>{
if (!contentType) return 'stream';
const cleanContent = contentType.split(';')[0]?.trim();
if (!cleanContent) return;
if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) return 'json';
if ('multipart/form-data' === cleanContent) return 'formData';
if ([
'application/',
'audio/',
'image/',
'video/'
].some((type)=>cleanContent.startsWith(type))) return 'blob';
if (cleanContent.startsWith('text/')) return 'text';
};
const checkForExistence = (options, name)=>{
if (!name) return false;
if (options.headers.has(name) || options.query?.[name] || options.headers.get('Cookie')?.includes(`${name}=`)) return true;
return false;
};
async function setAuthParams(options) {
for (const auth of options.security ?? []){
if (checkForExistence(options, auth.name)) continue;
const token = await getAuthToken(auth, options.auth);
if (!token) continue;
const name = auth.name ?? 'Authorization';
switch(auth.in){
case 'query':
if (!options.query) options.query = {};
options.query[name] = token;
break;
case 'cookie':
options.headers.append('Cookie', `${name}=${token}`);
break;
case 'header':
default:
options.headers.set(name, token);
break;
}
}
}
const buildUrl = (options)=>getUrl({
baseUrl: options.baseUrl,
path: options.path,
query: options.query,
querySerializer: 'function' == typeof options.querySerializer ? options.querySerializer : createQuerySerializer(options.querySerializer),
url: options.url
});
const mergeConfigs = (a, b)=>{
const config = {
...a,
...b
};
if (config.baseUrl?.endsWith('/')) config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
config.headers = mergeHeaders(a.headers, b.headers);
return config;
};
const headersEntries = (headers)=>{
const entries = [];
headers.forEach((value, key)=>{
entries.push([
key,
value
]);
});
return entries;
};
const mergeHeaders = (...headers)=>{
const mergedHeaders = new Headers();
for (const header of headers){
if (!header) continue;
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
for (const [key, value] of iterator)if (null === value) mergedHeaders.delete(key);
else if (Array.isArray(value)) for (const v of value)mergedHeaders.append(key, v);
else if (void 0 !== value) mergedHeaders.set(key, 'object' == typeof value ? JSON.stringify(value) : value);
}
return mergedHeaders;
};
class Interceptors {
fns = [];
clear() {
this.fns = [];
}
eject(id) {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) this.fns[index] = null;
}
exists(id) {
const index = this.getInterceptorIndex(id);
return Boolean(this.fns[index]);
}
getInterceptorIndex(id) {
if ('number' == typeof id) return this.fns[id] ? id : -1;
return this.fns.indexOf(id);
}
update(id, fn) {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = fn;
return id;
}
return false;
}
use(fn) {
this.fns.push(fn);
return this.fns.length - 1;
}
}
const createInterceptors = ()=>({
error: new Interceptors(),
request: new Interceptors(),
response: new Interceptors()
});
const defaultQuerySerializer = createQuerySerializer({
allowReserved: false,
array: {
explode: true,
style: 'form'
},
object: {
explode: true,
style: 'deepObject'
}
});
const defaultHeaders = {
'Content-Type': 'application/json'
};
const createConfig = (override = {})=>({
...jsonBodySerializer,
headers: defaultHeaders,
parseAs: 'auto',
querySerializer: defaultQuerySerializer,
...override
});
const createClient = (config = {})=>{
let _config = mergeConfigs(createConfig(), config);
const getConfig = ()=>({
..._config
});
const setConfig = (config)=>{
_config = mergeConfigs(_config, config);
return getConfig();
};
const interceptors = createInterceptors();
const beforeRequest = async (options)=>{
const opts = {
..._config,
...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers),
serializedBody: void 0
};
if (opts.security) await setAuthParams(opts);
if (opts.requestValidator) await opts.requestValidator(opts);
if (void 0 !== opts.body && opts.bodySerializer) opts.serializedBody = opts.bodySerializer(opts.body);
if (void 0 === opts.body || '' === opts.serializedBody) opts.headers.delete('Content-Type');
const resolvedOpts = opts;
const url = buildUrl(resolvedOpts);
return {
opts: resolvedOpts,
url
};
};
const request = async (options)=>{
const throwOnError = options.throwOnError ?? _config.throwOnError;
const responseStyle = options.responseStyle ?? _config.responseStyle;
let request;
let response;
try {
const { opts, url } = await beforeRequest(options);
const requestInit = {
redirect: 'follow',
...opts,
body: getValidRequestBody(opts)
};
request = new Request(url, requestInit);
for (const fn of interceptors.request.fns)if (fn) request = await fn(request, opts);
const _fetch = opts.fetch;
response = await _fetch(request);
for (const fn of interceptors.response.fns)if (fn) response = await fn(response, request, opts);
const result = {
request,
response
};
if (response.ok) {
const parseAs = ('auto' === opts.parseAs ? getParseAs(response.headers.get('Content-Type')) : opts.parseAs) ?? 'json';
if (204 === response.status || '0' === response.headers.get('Content-Length')) {
let emptyData;
switch(parseAs){
case 'arrayBuffer':
case 'blob':
case 'text':
emptyData = await response[parseAs]();
break;
case 'formData':
emptyData = new FormData();
break;
case 'stream':
emptyData = response.body;
break;
case 'json':
default:
emptyData = {};
break;
}
return 'data' === opts.responseStyle ? emptyData : {
data: emptyData,
...result
};
}
let data;
switch(parseAs){
case 'arrayBuffer':
case 'blob':
case 'formData':
case 'text':
data = await response[parseAs]();
break;
case 'json':
{
const text = await response.text();
data = text ? JSON.parse(text) : {};
break;
}
case 'stream':
return 'data' === opts.responseStyle ? response.body : {
data: response.body,
...result
};
}
if ('json' === parseAs) {
if (opts.responseValidator) await opts.responseValidator(data);
if (opts.responseTransformer) data = await opts.responseTransformer(data);
}
return 'data' === opts.responseStyle ? data : {
data,
...result
};
}
const textError = await response.text();
let jsonError;
try {
jsonError = JSON.parse(textError);
} catch {}
throw jsonError ?? textError;
} catch (error) {
let finalError = error;
for (const fn of interceptors.error.fns)if (fn) finalError = await fn(finalError, response, request, options);
finalError = finalError || {};
if (throwOnError) throw finalError;
return 'data' === responseStyle ? void 0 : {
error: finalError,
request,
response
};
}
};
const makeMethodFn = (method)=>(options)=>request({
...options,
method
});
const makeSseFn = (method)=>async (options)=>{
const { opts, url } = await beforeRequest(options);
return createSseClient({
...opts,
body: opts.body,
method,
onRequest: async (url, init)=>{
let request = new Request(url, init);
for (const fn of interceptors.request.fns)if (fn) request = await fn(request, opts);
return request;
},
serializedBody: getValidRequestBody(opts),
url
});
};
const _buildUrl = (options)=>buildUrl({
..._config,
...options
});
return {
buildUrl: _buildUrl,
connect: makeMethodFn('CONNECT'),
delete: makeMethodFn('DELETE'),
get: makeMethodFn('GET'),
getConfig,
head: makeMethodFn('HEAD'),
interceptors,
options: makeMethodFn('OPTIONS'),
patch: makeMethodFn('PATCH'),
post: makeMethodFn('POST'),
put: makeMethodFn('PUT'),
request,
setConfig,
sse: {
connect: makeSseFn('CONNECT'),
delete: makeSseFn('DELETE'),
get: makeSseFn('GET'),
head: makeSseFn('HEAD'),
options: makeSseFn('OPTIONS'),
patch: makeSseFn('PATCH'),
post: makeSseFn('POST'),
put: makeSseFn('PUT'),
trace: makeSseFn('TRACE')
},
trace: makeMethodFn('TRACE')
};
};
const client_gen_client = createClient(createConfig({
baseUrl: 'https://customer.region.trustedauth.com'
}));
const logoutUsingPost = (options)=>(options?.client ?? client_gen_client).post({
url: '/api/web/v1/authentication/logout',
...options
});
const userAuthenticateUsingPost = (options)=>(options.client ?? client_gen_client).post({
url: '/api/web/v1/authentication/users/authenticate/{authenticator}/complete',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
const userAuthenticatorQueryUsingPost = (options)=>(options.client ?? client_gen_client).post({
url: '/api/web/v2/authentication/users',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
const userChallengeUsingPost = (options)=>(options.client ?? client_gen_client).post({
url: '/api/web/v2/authentication/users/authenticate/{authenticator}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
const fetchOpenidConfiguration = async (issuerUrl)=>{
const wellKnownUrl = `${issuerUrl}/.well-known/openid-configuration`;
const response = await fetch(wellKnownUrl);
return await response.json();
};
const requestToken = async (tokenEndpoint, tokenRequest)=>{
const searchParams = new URLSearchParams({
...tokenRequest
});
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: searchParams
});
return await response.json();
};
const getUserInfo = async (userInfoEndpoint, accessToken)=>{
const response = await fetch(userInfoEndpoint, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`
}
});
return await response.text();
};
const queryUserAuthOptions = async (requestBody, baseUrl)=>{
const { data, error } = await userAuthenticatorQueryUsingPost({
baseUrl,
body: {
...requestBody
}
});
if (error) throw parseResponseError(error);
return data;
};
const requestAuthChallenge = async (requestBody, authenticator, baseUrl)=>{
const { data, error } = await userChallengeUsingPost({
baseUrl,
body: {
...requestBody
},
path: {
authenticator
}
});
if (error) throw parseResponseError(error);
return data;
};
const submitAuthChallenge = async (requestBody, authenticator, authorization, baseUrl)=>{
const { data, error } = await userAuthenticateUsingPost({
baseUrl,
headers: {
Authorization: authorization
},
body: {
...requestBody
},
path: {
authenticator
}
});
if (error) throw parseResponseError(error);
return data;
};
const logoutSilently = async (authorization, baseUrl)=>{
const { error } = await logoutUsingPost({
baseUrl,
headers: {
Authorization: `Bearer ${authorization}`
}
});
if (error) throw parseResponseError(error);
};
const getAuthRequestId = async (endpoint)=>{
const response = await fetch(endpoint, {
method: "POST"
});
const responseJson = await response.json();
if (!response.ok) throw new Error(responseJson.error_description, {
cause: responseJson.error
});
return responseJson;
};
const parseResponseError = (errorResponse)=>new Error(errorResponse.errorCode, {
cause: errorResponse.errorMessage
});
class IdaasContext {
#issuerUrl;
#clientId;
#tokenOptions;
#allowedIdTokenSigningAlgorithms;
#config;
constructor({ issuerUrl, clientId, tokenOptions, allowedIdTokenSigningAlgorithms }){
this.#tokenOptions = tokenOptions;
this.#allowedIdTokenSigningAlgorithms = allowedIdTokenSigningAlgorithms;
this.#issuerUrl = issuerUrl;
this.#clientId = clientId;
}
get issuerUrl() {
return this.#issuerUrl;
}
get clientId() {
return this.#clientId;
}
get tokenOptions() {
return this.#tokenOptions;
}
get allowedIdTokenSigningAlgorithms() {
return this.#allowedIdTokenSigningAlgorithms;
}
async getConfig() {
if (!this.#config) this.#config = await fetchOpenidConfiguration(this.issuerUrl);
return this.#config;
}
}
const formatUrl = (initialUrl)=>{
const input = initialUrl.includes("://") ? initialUrl : `https://${initialUrl}`;
const url = new URL(input);
if ("https:" !== url.protocol) {
if ("localhost" !== url.hostname || "http:" !== url.protocol) url.protocol = "https:";
}
const finalUrl = url.toString();
return finalUrl.endsWith("/") ? finalUrl.slice(0, -1) : finalUrl;
};
const calculateEpochExpiry = (expiresIn, authTime = Math.floor(Date.now() / 1000).toString())=>Number.parseInt(expiresIn) + Number.parseInt(authTime);
const sanitizeUri = (redirectUri)=>{
const sanitizedUrl = new URL(redirectUri);
sanitizedUrl.search = "";
return sanitizedUrl.toString();
};
const DEFAULT_ALLOWED_ID_TOKEN_SIGNING_ALGORITHMS = [
"PS256",
"PS384",
"PS512",
"RS256",
"RS384",
"RS512",
"EC256",
"ES384",
"ES512"
];
const isAllowedIdTokenSigningAlgorithm = (value)=>DEFAULT_ALLOWED_ID_TOKEN_SIGNING_ALGORITHMS.includes(value);
const validateIdToken = async ({ idToken, issuer, clientId, nonce, idTokenSigningAlgValuesSupported, allowedIdTokenSigningAlgorithms, acrValuesSupported, jwksEndpoint, requestedAcrValues })=>{
if (!idToken) throw new Error("No ID token supplied");
let stringifiedToken;
let decodedJwt;
let alg;
try {
if ("string" != typeof idToken) {
stringifiedToken = JSON.stringify(idToken);
decodedJwt = idToken;
alg = "none";
} else {
stringifiedToken = idToken;
decodedJwt = decodeJwt(idToken);
alg = decodeProtectedHeader(idToken).alg;
}
} catch {
throw new Error("ID token format is neither a valid JSON object nor a signed JWT");
}
if (!decodedJwt.sub) throw new Error("Subject (sub) claim is missing from ID token");
if (!decodedJwt.iat) throw new Error("Issued At (iat) claim is missing from ID token");
if ("number" != typeof decodedJwt.iat || !Number.isFinite(decodedJwt.iat)) throw new Error("Issued At (iat) claim must be a valid numeric timestamp");
if (!decodedJwt.iss) throw new Error("Issuer (iss) claim is missing from ID token");
if (!decodedJwt.aud) throw new Error("Audience (aud) claim is missing from ID token");
if (!decodedJwt.exp) throw new Error("Expiration Time (exp) claim is missing from the ID token");
if (decodedJwt.iss !== issuer) throw new Error(`Issuer (iss) claim ${decodedJwt.iss} in the ID token does not match expected ${issuer}`);
if ("string" == typeof decodedJwt.aud && decodedJwt.aud !== clientId) throw new Error(`Audience (aud) claim ${decodedJwt.aud} in the ID token does not match expected ${clientId}`);
if (Array.isArray(decodedJwt.aud)) {
if (!decodedJwt.aud.includes(clientId)) throw new Error(`Audience (aud) claim array ${decodedJwt.aud} in the ID token does not include expected ${clientId}`);
if (decodedJwt.aud.length > 1) {
const azp = decodedJwt.azp;
if (!azp) throw new Error("Authorized Party (azp) claim is missing from ID token and must be present when there are multiple audiences");
if (azp !== clientId) throw new Error(`Authorized Party (azp) claim ${azp} in the ID token does not match expected ${clientId}`);
}
}
const leeway = 15;
const now = new Date();
const expDate = new Date((decodedJwt.exp + leeway) * 1000);
if (now > expDate) throw new Error(`Expiration Time (exp) claim ${decodedJwt.exp} indicates that this token is now expired at ${now}`);
if (decodedJwt.iat > decodedJwt.exp) throw new Error(`Issued At (iat) claim ${decodedJwt.iat} must not be later than Expiration Time (exp) claim ${decodedJwt.exp}`);
const iatDate = new Date((decodedJwt.iat - leeway) * 1000);
if (now < iatDate) throw new Error(`Issued At (iat) claim ${decodedJwt.iat} indicates that this token was issued in the future`);
if (decodedJwt.nbf) {
const nbfDate = new Date((decodedJwt.nbf - leeway) * 1000);
if (now < nbfDate) throw new Error(`Not Before (nbf) claim ${decodedJwt.nbf} indicates that this token is not to be used yet at ${now}`);
}
const nonceClaim = decodedJwt.nonce;
if (!nonceClaim) throw new Error("Nonce (nonce) claim is missing from ID token");
if (nonceClaim !== nonce) throw new Error(`Nonce (nonce) claim ${nonceClaim} in the ID token does not match expected ${nonce}`);
const acrClaim = decodedJwt.acr;
if (acrClaim && !acrValuesSupported?.includes(acrClaim)) throw new Error(`Authentication Context Class Reference (acr) claim ${acrClaim} is not one of the supported ${acrValuesSupported}`);
if (!alg) throw new Error("Algorithm (alg) claim is missing from ID token");
if (allowedIdTokenSigningAlgorithms?.length === 0) throw new Error("Allowed ID token signing algorithms list cannot be empty when provided");
const requestedAllowedAlgorithms = allowedIdTokenSigningAlgorithms ?? DEFAULT_ALLOWED_ID_TOKEN_SIGNING_ALGORITHMS;
const invalidAlgorithms = requestedAllowedAlgorithms.filter((requestedAlg)=>!isAllowedIdTokenSigningAlgorithm(requestedAlg));
if (invalidAlgorithms.length > 0) throw new Error(`Allowed ID token signing algorithms contains unsupported values: ${invalidAlgorithms.join(", ")}. Supported values are ${DEFAULT_ALLOWED_ID_TOKEN_SIGNING_ALGORITHMS.join(", ")}`);
const effectiveAllowedAlgorithms = requestedAllowedAlgorithms.filter((requestedAlg)=>idTokenSigningAlgValuesSupported.includes(requestedAlg));
if (0 === effectiveAllowedAlgorithms.length) throw new Error(`No allowed ID token signing algorithms are supported by the OpenID Provider. Requested: ${requestedAllowedAlgorithms.join(", ")}; Provider supports: ${idTokenSigningAlgValuesSupported.join(", ")}`);
if (!effectiveAllowedAlgorithms.includes(alg)) throw new Error(`Algorithm (alg) claim ${alg} in the ID token is not one of the allowed algorithms ${effectiveAllowedAlgorithms}`);
if ("string" == typeof idToken) {
const jwks = createRemoteJWKSet(new URL(jwksEndpoint));
try {
await jwtVerify(idToken, jwks, {
audience: clientId,
issuer
});
} catch (error) {
throw new Error(`ID token signature verification failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (requestedAcrValues && requestedAcrValues.length > 0) {
if (!acrClaim) throw new Error("Authentication Context Class Reference (acr) claim is missing from ID token");
if ("string" != typeof acrClaim || !requestedAcrValues.includes(acrClaim)) throw new Error(`Authentication Context Class Reference (acr) claim ${acrClaim} in the ID token is not one of the requested values ${requestedAcrValues}`);
}
return {
idToken: stringifiedToken,
decodedJwt
};
};
const validateUserInfoToken = async ({ userInfoToken, issuer, clientId, jwksEndpoint })=>{
try {
decodeJwt(userInfoToken);
} catch {
return null;
}
const jwks = createRemoteJWKSet(new URL(jwksEndpoint));
const verifiedJwt = await jwtVerify(userInfoToken, jwks, {
audience: clientId,
issuer
});
return verifiedJwt.payload;
};
const readAccessToken = (encodedToken)=>{
let decodedToken;
try {
decodedToken = decodeJwt(encodedToken);
} catch {
return null;
}
return decodedToken;
};
const createRandomString = ()=>{
const randomNumbers = window.crypto.getRandomValues(new Uint8Array(32));
return String.fromCharCode(...randomNumbers);
};
const base64UrlStringEncode = (str)=>btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const base64UrlOctetEncode = (array)=>base64UrlStringEncode(String.fromCharCode(...array));
const generateChallengeVerifierPair = async ()=>{
const randomString = createRandomString();
const codeVerifier = base64UrlStringEncode(randomString);
const codeChallenge = await createCodeChallenge(codeVerifier);
return {
codeVerifier,
codeChallenge
};
};
const createCodeChallenge = async (codeVerifier)=>{
const hash = await sha256(codeVerifier);
return base64UrlOctetEncode(hash);
};
const sha256 = async (string)=>{
const encoder = new TextEncoder();
const data = encoder.encode(string);
const hash = await window.crypto.subtle.digest("SHA-256", data);
return new Uint8Array(hash);
};
const generateAuthorizationUrl = async (oidcConfig, options)=>{
let baseUrl;
baseUrl = "jwt" === options.type ? `${oidcConfig.issuer}/authorizejwt` : oidcConfig.authorization_endpoint;
const scopeAsArray = options.tokenOptions.scope ? options.tokenOptions.scope.split(" ").filter(Boolean) : [];
if (false !== options.tokenOptions.includeOpenidScope) scopeAsArray.push("openid");
if (options.tokenOptions.useRefreshToken) scopeAsArray.push("offline_access");
const usedScope = [
...new Set(scopeAsArray)
].join(" ");
const state = base64UrlStringEncode(createRandomString());
const nonce = base64UrlStringEncode(createRandomString());
const { codeVerifier, codeChallenge } = await generateChallengeVerifierPair();
const url = new URL(baseUrl);
url.searchParams.append("client_id", options.clientId);
url.searchParams.append("scope", usedScope);
url.searchParams.append("state", state);
url.searchParams.append("nonce", nonce);
url.searchParams.append("code_challenge", codeChallenge);
url.searchParams.append("code_challenge_method", "S256");
if (options.tokenOptions.audience) url.searchParams.append("audience", options.tokenOptions.audience);
if (void 0 !== options.tokenOptions.maxAge && options.tokenOptions.maxAge >= 0) url.searchParams.append("max_age", options.tokenOptions.maxAge.toString());
if (options.tokenOptions.acrValues && options.tokenOptions.acrValues.trim().length > 0) url.searchParams.append("acr_values", options.tokenOptions.acrValues);
url.searchParams.append("response_type", "code");
if (scopeAsArray.includes("offline_access")) url.searchParams.append("prompt", "consent");
if ("standard" === options.type) {
if (options.responseMode) url.searchParams.append("response_mode", options.responseMode);
if (options.redirectUri) url.searchParams.append("redirect_uri", options.redirectUri);
}
return {
url: url.toString(),
nonce,
state,
codeVerifier,
usedScope
};
};
class OidcClient {
#context;
#storageManager;
constructor(context, storageManager){
this.#context = context;
this.#storageManager = storageManager;
}
async login({ redirectUri, popup } = {}, tokenOptions = {}) {
if (popup) {
const popupWindow = openPopup("");
const { response_modes_supported } = await this.#context.getConfig();
const popupSupported = response_modes_supported?.includes("web_message");
if (!popupSupported) {
popupWindow.close();
throw new Error("Attempted to use popup but web_message is not supported by OpenID provider.");
}
return await this.#loginWithPopup({
redirectUri
}, tokenOptions);
}
await this.#loginWithRedirect({
redirectUri
}, tokenOptions);
return null;
}
async logout({ redirectUri } = {}) {
this.#storageManager.remove();
window.location.href = await this.#generateLogoutUrl(redirectUri);
}
async handleRedirect() {
const { authorizeResponse } = this.#parseRedirect();
if (!authorizeResponse) return null;
const clientParams = this.#storageManager.getClientParams();
if (!clientParams) throw new Error("Failed to recover IDaaS client state from local storage");
const { codeVerifier, redirectUri, state, nonce } = clientParams;
const tokenParams = this.#storageManager.getTokenParams();
if (!tokenParams) throw new Error("No token params stored, unable to parse");
const authorizeCode = this.#validateAuthorizeResponse(authorizeResponse, state);
const validatedTokenResponse = await this.#requestAndValidateTokens(authorizeCode, codeVerifier, redirectUri, nonce, tokenParams.requireIdToken ?? true, tokenParams.acrValues?.split(" ").filter(Boolean));
this.#parseAndSaveTokenResponse(validatedTokenResponse, tokenParams);
return null;
}
#parseRedirect() {
const url = new URL(window.location.href);
const searchParams = url.searchParams;
if ("" === searchParams.toString()) return {
authorizeResponse: null
};
const authorizeResponse = this.#parseLoginRedirect(searchParams);
return {
authorizeResponse
};
}
#parseLoginRedirect(searchParams) {
const state = searchParams.get("state");
const code = searchParams.get("code");
const error = searchParams.get("error");
const error_description = searchParams.get("error_description");
if (!state) return null;
if (!(code || error)) return null;
const url = new URL(window.location.href);