UNPKG

@entrustcorp/idaas-auth-js

Version:
1,242 lines (1,241 loc) 90.4 kB
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);