From 1ab1ed4f9a4d78e874414848727d2db4c5cbcb38 Mon Sep 17 00:00:00 2001 From: Viktor Smorodin Date: Tue, 20 May 2025 19:02:59 +0300 Subject: [PATCH 1/3] feat(http-service): add token refresh --- .../src/constants/axiosRestController.ts | 118 +++++++++++++++--- services/http-service/src/httpService.ts | 6 +- services/http-service/src/httpStatus.ts | 50 ++++++++ services/http-service/src/restController.ts | 7 +- services/http-service/src/token.types.ts | 22 ++++ 5 files changed, 183 insertions(+), 20 deletions(-) create mode 100644 services/http-service/src/httpStatus.ts create mode 100644 services/http-service/src/token.types.ts diff --git a/services/http-service/src/constants/axiosRestController.ts b/services/http-service/src/constants/axiosRestController.ts index dcf2e659..27ad6fac 100644 --- a/services/http-service/src/constants/axiosRestController.ts +++ b/services/http-service/src/constants/axiosRestController.ts @@ -1,5 +1,7 @@ -import axios, { AxiosInstance, AxiosResponse } from 'axios'; +import axios, { AxiosInstance, AxiosResponse, AxiosError } from 'axios'; import HttpRestController from '../restController'; +import { ITokenData, ITokenPayload } from '../token.types'; +import { httpStatus } from '../httpStatus'; const DEFAULT_REQUEST_TIMEOUT = 60000; @@ -8,6 +10,7 @@ export class HttpRestControllerAxios extends HttpRestController { constructor() { super(); + this.axiosInstance = axios.create({ headers: { 'X-Requested-With': 'XMLHttpRequest', @@ -17,59 +20,142 @@ export class HttpRestControllerAxios extends HttpRestController { }, timeout: DEFAULT_REQUEST_TIMEOUT }); + + this.setupInterceptors(); } + private tokenData: ITokenData | null = null; + + private refreshTokenRequest: Promise | null = null; + + private isTokenExpired(): boolean { + const MILLISECONDS_PER_SECOND = 1000; + const expireTime = this.tokenData == null ? null : this.tokenData.expireTime(); + + if (!expireTime) return false; + + return Math.round(new Date().getTime() / MILLISECONDS_PER_SECOND) > expireTime; + } + + private async refreshTokens(): Promise { + if (this.refreshTokenRequest) { + return this.refreshTokenRequest; + } + + if (!this.tokenData?.refreshToken) { + throw new Error('No refresh token available'); + } + + try { + this.refreshTokenRequest = this + .post(this.tokenData.tokenRefreshUrl, { refreshToken: this.tokenData.refreshToken }) + .then(response => { + this.tokenData?.setNewTokenPayload(response.data); + + return response.data.accessToken; + }); + + return this.refreshTokenRequest; + } catch (error) { + throw new Error(`Token refresh failed: ${error}`); + } finally { + this.refreshTokenRequest = null; + } + } + + private setupInterceptors(): void { + this.axiosInstance.interceptors.request.use(async request => { + const accessToken = this.tokenData?.accessToken(); + const isExpired = this.isTokenExpired(); + const isNotRefreshUrl = request.url !== this.tokenData?.tokenRefreshUrl; + + if (accessToken && !isExpired) { + this.setHeader('Authorization', `Bearer ${accessToken}`); + } + + if (isExpired && isNotRefreshUrl) { + const newAccessToken = await this.refreshTokens(); + this.setHeader('Authorization', `Bearer ${newAccessToken}`); + } + + return request; + }); + + this.axiosInstance.interceptors.response.use( + response => response, + async (error: AxiosError) => { + const requestConfig = error.config; + + if (error.response?.status === (httpStatus.UNAUTHORIZED as number)) { + try { + const newAccessToken = await this.refreshTokens(); + + requestConfig?.headers.set('Authorization', `Bearer ${newAccessToken}`); + + return this.axiosInstance(requestConfig ?? {}); + } catch (refreshError) { + console.error('Session expired. Redirect to login...'); + this.tokenData?.clearTokens(); + window.location.href = '/login'; + + return Promise.reject(refreshError); + } + } + + throw error; + } + ); + } + + setTokenData(tokenData: ITokenData): void { + this.tokenData = tokenData; + }; + setHeader = (key: string, value: string | null): void => { this.axiosInstance.defaults.headers[key] = value; }; - get = async >(url: string, params: object = {}, options: object = {}): Promise => { - return this.axiosInstance.get(url, { + get = async >(url: string, params: object = {}, options: object = {}): Promise => + this.axiosInstance.get(url, { params, ...options }); - }; post = async >( url: string, body?: object, params: object = {}, options: object = {} - ): Promise => { - return this.axiosInstance.post(url, body, { + ): Promise => this.axiosInstance.post(url, body, { params, ...options }); - }; patch = async >( url: string, body?: object, params: object = {}, options: object = {} - ): Promise => { - return this.axiosInstance.patch(url, body, { + ): Promise => + this.axiosInstance.patch(url, body, { params, ...options }); - }; put = async >( url: string, body?: object, params: object = {}, options: object = {} - ): Promise => { - return this.axiosInstance.put(url, body, { + ): Promise => + this.axiosInstance.put(url, body, { params, ...options }); - }; - delete = async >(url: string, params: object = {}, options: object = {}): Promise => { - return this.axiosInstance.delete(url, { + delete = async >(url: string, params: object = {}, options: object = {}): Promise => + this.axiosInstance.delete(url, { params, ...options }); - }; } diff --git a/services/http-service/src/httpService.ts b/services/http-service/src/httpService.ts index a68085f4..a7afb909 100644 --- a/services/http-service/src/httpService.ts +++ b/services/http-service/src/httpService.ts @@ -10,11 +10,11 @@ class HttpService { public put: RestController['put']; public delete: RestController['delete']; public setHeader: RestController['setHeader']; + public setTokenData: RestController['setTokenData']; constructor(options: IHttpServiceOptions) { if (options.restController) { - const restController = options.restController; - this.restController = restController; + this.restController = options.restController; this.get = this.restController.get; this.post = this.restController.post; @@ -22,6 +22,7 @@ class HttpService { this.put = this.restController.put; this.delete = this.restController.delete; this.setHeader = this.restController.setHeader; + this.setTokenData = this.restController.setTokenData; } else { this.get = () => Promise.reject('get handler was not specified'); this.post = () => Promise.reject('post handler was not specified'); @@ -29,6 +30,7 @@ class HttpService { this.put = () => Promise.reject('put handler was not specified'); this.delete = () => Promise.reject('delete handler was not specified'); this.setHeader = () => undefined; + this.setTokenData = () => undefined; } } } diff --git a/services/http-service/src/httpStatus.ts b/services/http-service/src/httpStatus.ts new file mode 100644 index 00000000..8d07018e --- /dev/null +++ b/services/http-service/src/httpStatus.ts @@ -0,0 +1,50 @@ +export declare enum httpStatus { + CONTINUE = 100, + SWITCHING_PROTOCOLS = 101, + PROCESSING = 102, + EARLYHINTS = 103, + OK = 200, + CREATED = 201, + ACCEPTED = 202, + NON_AUTHORITATIVE_INFORMATION = 203, + NO_CONTENT = 204, + RESET_CONTENT = 205, + PARTIAL_CONTENT = 206, + AMBIGUOUS = 300, + MOVED_PERMANENTLY = 301, + FOUND = 302, + SEE_OTHER = 303, + NOT_MODIFIED = 304, + TEMPORARY_REDIRECT = 307, + PERMANENT_REDIRECT = 308, + BAD_REQUEST = 400, + UNAUTHORIZED = 401, + PAYMENT_REQUIRED = 402, + FORBIDDEN = 403, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + NOT_ACCEPTABLE = 406, + PROXY_AUTHENTICATION_REQUIRED = 407, + REQUEST_TIMEOUT = 408, + CONFLICT = 409, + GONE = 410, + LENGTH_REQUIRED = 411, + PRECONDITION_FAILED = 412, + PAYLOAD_TOO_LARGE = 413, + URI_TOO_LONG = 414, + UNSUPPORTED_MEDIA_TYPE = 415, + REQUESTED_RANGE_NOT_SATISFIABLE = 416, + EXPECTATION_FAILED = 417, + I_AM_A_TEAPOT = 418, + MISDIRECTED = 421, + UNPROCESSABLE_ENTITY = 422, + FAILED_DEPENDENCY = 424, + PRECONDITION_REQUIRED = 428, + TOO_MANY_REQUESTS = 429, + INTERNAL_SERVER_ERROR = 500, + NOT_IMPLEMENTED = 501, + BAD_GATEWAY = 502, + SERVICE_UNAVAILABLE = 503, + GATEWAY_TIMEOUT = 504, + HTTP_VERSION_NOT_SUPPORTED = 505 +} diff --git a/services/http-service/src/restController.ts b/services/http-service/src/restController.ts index c3d29746..e944d6af 100644 --- a/services/http-service/src/restController.ts +++ b/services/http-service/src/restController.ts @@ -1,12 +1,15 @@ +import { ITokenData } from './token.types'; + abstract class HttpRestController { abstract get(...args: any[]): Promise; abstract post(...args: any[]): Promise; abstract patch(...args: any[]): Promise; abstract put(...args: any[]): Promise; abstract delete(...args: any[]): Promise; - public setHeader: (...args: any[]) => void; + abstract setTokenData(arg: ITokenData): void; + setHeader: (...args: any[]) => void; - constructor() { + protected constructor() { this.setHeader = () => { console.error('setHeader for HttpRestController is undefined'); }; diff --git a/services/http-service/src/token.types.ts b/services/http-service/src/token.types.ts new file mode 100644 index 00000000..ee4e09c8 --- /dev/null +++ b/services/http-service/src/token.types.ts @@ -0,0 +1,22 @@ +export type TValueOrGetter = T extends Function ? never : T | (() => T); +export type TGetter = () => T; + +export interface ITokenPayload { + accessToken: string; + refreshToken: string; + expiresIn: number; +} + +export type TAccessTokenGetter = TGetter; +export type TRefreshTokenGetter = TGetter; +export type TTokenExpireTimeGetter = TGetter; +export type TTokenRefreshUrlGetter = string; + +export interface ITokenData { + accessToken: TAccessTokenGetter; + refreshToken: TRefreshTokenGetter; + expireTime: TTokenExpireTimeGetter; + tokenRefreshUrl: TTokenRefreshUrlGetter; + setNewTokenPayload: (arg: ITokenPayload) => void; + clearTokens: () => void; +} From b63bc53c445b0d8ec8fc68e4b7b4b15b75847b49 Mon Sep 17 00:00:00 2001 From: Viktor Smorodin Date: Wed, 21 May 2025 13:12:44 +0300 Subject: [PATCH 2/3] feat(http-service): update token refresh --- package-lock.json | 13 +++++++-- .../src/constants/axiosRestController.ts | 28 +++++++++++++------ 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0fb55802..258c6fd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -707,8 +707,8 @@ "resolved": "components/highlighter", "link": true }, - "node_modules/@byndyusoft-ui/http-request": { - "resolved": "services/http-request", + "node_modules/@byndyusoft-ui/http-service": { + "resolved": "services/http-service", "link": true }, "node_modules/@byndyusoft-ui/keyframes-css": { @@ -11175,6 +11175,15 @@ "services/http-request": { "name": "@byndyusoft-ui/http-request", "version": "0.0.1", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "axios": "^1.9.0" + } + }, + "services/http-service": { + "name": "@byndyusoft-ui/http-service", + "version": "0.0.1", "license": "Apache-2.0", "dependencies": { "axios": "^1.9.0" diff --git a/services/http-service/src/constants/axiosRestController.ts b/services/http-service/src/constants/axiosRestController.ts index 27ad6fac..624b8178 100644 --- a/services/http-service/src/constants/axiosRestController.ts +++ b/services/http-service/src/constants/axiosRestController.ts @@ -53,10 +53,13 @@ export class HttpRestControllerAxios extends HttpRestController { this.tokenData?.setNewTokenPayload(response.data); return response.data.accessToken; - }); + }) return this.refreshTokenRequest; } catch (error) { + this.tokenData?.clearTokens(); + window.location.href = '/login'; + throw new Error(`Token refresh failed: ${error}`); } finally { this.refreshTokenRequest = null; @@ -70,12 +73,18 @@ export class HttpRestControllerAxios extends HttpRestController { const isNotRefreshUrl = request.url !== this.tokenData?.tokenRefreshUrl; if (accessToken && !isExpired) { - this.setHeader('Authorization', `Bearer ${accessToken}`); + request.headers['Authorization'] = `Bearer ${accessToken}`; } if (isExpired && isNotRefreshUrl) { - const newAccessToken = await this.refreshTokens(); - this.setHeader('Authorization', `Bearer ${newAccessToken}`); + try { + const newAccessToken = await this.refreshTokens(); + request.headers['Authorization'] = `Bearer ${newAccessToken}`; + } catch (refreshError) { + console.error('Session expired'); + + return Promise.reject(refreshError); + } } return request; @@ -85,18 +94,19 @@ export class HttpRestControllerAxios extends HttpRestController { response => response, async (error: AxiosError) => { const requestConfig = error.config; + const isNotRefreshUrl = error.config?.url !== this.tokenData?.tokenRefreshUrl; - if (error.response?.status === (httpStatus.UNAUTHORIZED as number)) { + if (error.response?.status === (httpStatus.UNAUTHORIZED as number) && isNotRefreshUrl) { try { const newAccessToken = await this.refreshTokens(); - requestConfig?.headers.set('Authorization', `Bearer ${newAccessToken}`); + if (requestConfig) { + requestConfig.headers['Authorization'] = `Bearer ${newAccessToken}`; + } return this.axiosInstance(requestConfig ?? {}); } catch (refreshError) { - console.error('Session expired. Redirect to login...'); - this.tokenData?.clearTokens(); - window.location.href = '/login'; + console.error('Session expired'); return Promise.reject(refreshError); } From 9fc569b5780f60f227512686e16b40b2362394de Mon Sep 17 00:00:00 2001 From: Viktor Smorodin Date: Wed, 21 May 2025 13:29:48 +0300 Subject: [PATCH 3/3] feat(http-service): remove arrow functions --- .../src/constants/axiosRestController.ts | 45 ++++++++----------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/services/http-service/src/constants/axiosRestController.ts b/services/http-service/src/constants/axiosRestController.ts index 624b8178..ba813cd3 100644 --- a/services/http-service/src/constants/axiosRestController.ts +++ b/services/http-service/src/constants/axiosRestController.ts @@ -125,47 +125,38 @@ export class HttpRestControllerAxios extends HttpRestController { this.axiosInstance.defaults.headers[key] = value; }; - get = async >(url: string, params: object = {}, options: object = {}): Promise => - this.axiosInstance.get(url, { - params, - ...options - }); + async get>(url: string, params: object = {}, options: object = {}): Promise { + return this.axiosInstance.get(url, { params, ...options }); + }; - post = async >( + async post>( url: string, body?: object, params: object = {}, options: object = {} - ): Promise => this.axiosInstance.post(url, body, { - params, - ...options - }); + ): Promise { + return this.axiosInstance.post(url, body, { params, ...options }); + }; - patch = async >( + async patch>( url: string, body?: object, params: object = {}, options: object = {} - ): Promise => - this.axiosInstance.patch(url, body, { - params, - ...options - }); + ): Promise { + return this.axiosInstance.patch(url, body, { params, ...options }); + }; - put = async >( + async put>( url: string, body?: object, params: object = {}, options: object = {} - ): Promise => - this.axiosInstance.put(url, body, { - params, - ...options - }); + ): Promise { + return this.axiosInstance.put(url, body, { params, ...options }); + } - delete = async >(url: string, params: object = {}, options: object = {}): Promise => - this.axiosInstance.delete(url, { - params, - ...options - }); + async delete>(url: string, params: object = {}, options: object = {}): Promise { + return this.axiosInstance.delete(url, { params, ...options }); + }; }