import { Log } from '@servicetitan/log-service'; import { inject, injectable, optional, provide, Store, SymbolToken } from '@servicetitan/react-ioc'; import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, CanceledError } from 'axios'; import { ComponentType, FC, PropsWithChildren } from 'react'; import { loader } from '../loader'; import { AuthAdapter, BearerTokenAuth, TokenServerAuth, TokenServerAuthError, } from './auth-adapters'; export const RETRY_LIMIT = 1; export interface WithMicroserviceContext { authURL?: string; baseURL: string; } type AuthAdapterCallback = (context: WithMicroserviceContext) => AuthAdapter; export interface WithMicroserviceOptions { authURL?: string; axiosInstance?: AxiosInstance; baseURL: string; tokens?: SymbolToken[]; component: ComponentType>; authAdapter?: 'bearer' | 'token' | AuthAdapterCallback; extraHeaders?: { [key: string]: any }; /** * Run authentication method as soon as the component is mounted. */ preAuthenticate?: boolean; preAuthenticateCallback?: (error?: any) => void; } export interface AxiosRequestConfigAuthentication { skipAuth?: boolean; } interface AxiosRequestConfigMetadata { metadata?: { authAdapterId?: symbol; retries?: number; }; } type AuthRequestConfig = AxiosRequestConfig & AxiosRequestConfigAuthentication & AxiosRequestConfigMetadata; export function withMicroservice(options: WithMicroserviceOptions) { const { authURL, axiosInstance = axios, baseURL, tokens = [], component: UnwrappedComponent, authAdapter = 'bearer', extraHeaders = undefined, } = options; @injectable() class MicroserviceAuthStore extends Store { private authAdapter: AuthAdapter; private authAdapterId: symbol; private authenticatePromise?: Promise; private axiosRequestInterceptor?: number; private axiosResponseInterceptor?: number; private isMounted?: boolean; private readonly log?: Log; private requestQueue: { retry: () => void; reject: () => void; cancel: () => void }[] = []; constructor(@optional() @inject(Log) log?: Log) { super(); const context: WithMicroserviceContext = { authURL, baseURL }; this.authAdapter = this.createAuthAdapter(authAdapter, context); this.authAdapterId = Symbol('authAdapterId'); this.log = log; } initialize() { this.isMounted = true; this.addInterceptors(); /* * When using TokenServerAuth, we need to always authenticate before making requests. * If we do not, then there is a chance that the microservice is already authenticated * with the wrong user. Because of this, requests may never be 401s, resulting in * authentication never happening, and the incorrect user remaining. Ensuring * authentication immediately on mount ensures that authentication occurs before making * requests, and ensures that the correct user is being used. */ const preAuthenticate = this.authAdapter instanceof TokenServerAuth || options.preAuthenticate; if (preAuthenticate) { this.authenticate() .then(() => { options.preAuthenticateCallback?.(); }) .catch(error => { if (options.preAuthenticateCallback) { options.preAuthenticateCallback(error); return; } if ( error instanceof TokenServerAuthError || error instanceof CanceledError ) { return; } const category = 'AjaxHandlers.PreAuthenticate'; const message = 'unexpected error, check token server configuration'; if (this.log) { this.log.error({ category, message, error }); } else { // eslint-disable-next-line no-console console.error(category, message, { error }); } }); } } dispose() { try { this.authAdapter.onDestroy?.(); } catch (e) { // eslint-disable-next-line no-console console.error('Error destroying auth adapter', e); } this.authenticatePromise = undefined; this.removeInterceptors(); this.isMounted = false; } private get isAuthenticating() { return Boolean(this.authenticatePromise); } private createAuthAdapter( authAdapter: WithMicroserviceOptions['authAdapter'], context: WithMicroserviceContext ): AuthAdapter { if (typeof authAdapter === 'function') { return authAdapter(context); } if (authAdapter === 'bearer') { return new BearerTokenAuth(context); } if (authAdapter === 'token') { return new TokenServerAuth(context); } throw new Error(`unrecognized authAdapter: ${authAdapter}`); } private addInterceptors() { this.addRequestInterceptor(); this.addResponseInterceptor(); } private removeInterceptors() { this.removeRequestInterceptor(); this.removeResponseInterceptor(); } private addRequestInterceptor() { this.axiosRequestInterceptor = axiosInstance.interceptors.request.use(async config => { if (!this.shouldInterceptRequest(config)) { return config; } this.claim(config); config.suppressShowError = [401]; if (this.isAuthenticating) { try { // Wait for authentication to complete await this.authenticatePromise; } catch { // If authentication was canceled or fails, cancel this request const controller = new AbortController(); controller.abort(); return { ...config, signal: controller.signal, }; } } this.authAdapter.interceptRequest?.(config); this.addExtraHeaders(config); return config; }); } private addResponseInterceptor() { this.axiosResponseInterceptor = axiosInstance.interceptors.response.use( response => response, (axiosError: AxiosError) => { if (!this.shouldInterceptResponseError(axiosError)) { return Promise.reject(axiosError); } const config: AuthRequestConfig = axiosError.response?.config ?? {}; if (this.isAuthenticating) { // Queue up request while authenticate is in progress (triggered by another request) return new Promise((resolve, reject) => { this.requestQueue.push({ retry: () => resolve(this.fetchRequestWithRetryMetadata(config)), reject: () => reject(axiosError), cancel: () => reject(new CanceledError()), }); }); } if (this.authAdapter.didAuthenticationHappenWhileRequestInFlight?.(config)) { return this.fetchRequestWithRetryMetadata(config); } return this.authenticate(config) .catch(e => { // If authentication was canceled, cancel all api requests (current and queued) if (axios.isCancel(e)) { this.requestQueue.forEach(request => request.cancel()); return Promise.reject(e); } // Otherwise, authentication failed, so reject all with original error (401) this.requestQueue.forEach(request => request.reject()); /* * Reject with 401 error from original request instead of auth error * This is because if auth fails, the code that called the request that * required authentication should not need to be aware of how to handle * the auth error, but only a 401 error. */ return Promise.reject(axiosError); }) .then(() => { /* * If any other requests requiring authentication were attempting to be made * while this request was running, they were queued and will now be run now * that the authentication has finished. */ this.requestQueue.forEach(request => request.retry()); this.requestQueue = []; return this.fetchRequestWithRetryMetadata(config); }); } ); } private removeRequestInterceptor() { if (this.axiosRequestInterceptor !== undefined) { axiosInstance.interceptors.request.eject(this.axiosRequestInterceptor); } } private removeResponseInterceptor() { if (this.axiosResponseInterceptor !== undefined) { axiosInstance.interceptors.response.eject(this.axiosResponseInterceptor); } } private shouldInterceptRequest(config: AuthRequestConfig) { return ( this.hasClaimed(config) || (this.isUnclaimed(config) && !config.skipAuth && this.baseURLMatchesConfig(config)) ); } private shouldInterceptResponseError(error: AxiosError) { const response = error.response; const config: AuthRequestConfig = response?.config ?? {}; if ((config.metadata?.retries ?? 0) >= RETRY_LIMIT) { // eslint-disable-next-line no-console console.error(`withMicroservice has reached request retry limit for ${config.url}`); return false; } return ( !!this.isMounted && !axios.isCancel(error) && this.hasClaimed(config) && response?.status === 401 ); } private fetchRequestWithRetryMetadata(config: AuthRequestConfig) { config.metadata ??= {}; config.metadata.retries = (config.metadata.retries ?? 0) + 1; return axiosInstance(config); } private claim(config: AuthRequestConfig) { config.metadata ??= {}; config.metadata.authAdapterId = this.authAdapterId; } private hasClaimed(config: AuthRequestConfig) { return config.metadata?.authAdapterId === this.authAdapterId; } private isUnclaimed(config: AuthRequestConfig) { return !config.metadata?.authAdapterId; } private authenticate(config?: AxiosRequestConfig) { if (!config?.hideGlobalLoader) { loader.show(); } this.authenticatePromise = this.authAdapter.authenticate().finally(() => { this.authenticatePromise = undefined; if (!config?.hideGlobalLoader) { loader.hide(); } }); return this.authenticatePromise; } private addExtraHeaders(config: AxiosRequestConfig) { if (!extraHeaders) { return; } config.headers = { ...config.headers, ...extraHeaders, }; } private baseURLMatchesConfig(config: AxiosRequestConfig) { return config.baseURL === baseURL || config.url?.startsWith(baseURL); } } const apiBaseUrlConfigs = tokens.map(token => ({ provide: token, useValue: baseURL, })); const WrappedComponent = provide({ singletons: [MicroserviceAuthStore, ...apiBaseUrlConfigs], })(UnwrappedComponent) as FC>; return WrappedComponent; }