GRPCServiceProxy.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. import { AxiosInstance, AxiosError, AxiosResponse, Method } from 'axios'
  2. import { Request, Response } from 'express'
  3. import { Logger } from 'winston'
  4. import { TimerInterface } from '@standardnotes/time'
  5. import { IAuthClient, AuthorizationHeader, SessionValidationResponse } from '@standardnotes/grpc'
  6. import * as grpc from '@grpc/grpc-js'
  7. import { CrossServiceTokenCacheInterface } from '../Cache/CrossServiceTokenCacheInterface'
  8. import { ServiceProxyInterface } from '../Proxy/ServiceProxyInterface'
  9. import { GRPCSyncingServerServiceProxy } from './GRPCSyncingServerServiceProxy'
  10. import { Status } from '@grpc/grpc-js/build/src/constants'
  11. import { ResponseLocals } from '../../Controller/ResponseLocals'
  12. import { OfflineResponseLocals } from '../../Controller/OfflineResponseLocals'
  13. export class GRPCServiceProxy implements ServiceProxyInterface {
  14. constructor(
  15. private httpClient: AxiosInstance,
  16. private authServerUrl: string,
  17. private syncingServerJsUrl: string,
  18. private paymentsServerUrl: string,
  19. private filesServerUrl: string,
  20. private webSocketServerUrl: string,
  21. private revisionsServerUrl: string,
  22. private emailServerUrl: string,
  23. private httpCallTimeout: number,
  24. private crossServiceTokenCache: CrossServiceTokenCacheInterface,
  25. private logger: Logger,
  26. private timer: TimerInterface,
  27. private authClient: IAuthClient,
  28. private gRPCSyncingServerServiceProxy: GRPCSyncingServerServiceProxy,
  29. ) {}
  30. async validateSession(
  31. headers: {
  32. authorization: string
  33. sharedVaultOwnerContext?: string
  34. },
  35. retryAttempt?: number,
  36. ): Promise<{ status: number; data: unknown; headers: { contentType: string } }> {
  37. const promise = new Promise((resolve, reject) => {
  38. try {
  39. const request = new AuthorizationHeader()
  40. request.setBearerToken(headers.authorization)
  41. const metadata = new grpc.Metadata()
  42. metadata.set('x-shared-vault-owner-context', headers.sharedVaultOwnerContext ?? '')
  43. this.logger.debug('[GRPCServiceProxy] Validating session via gRPC')
  44. this.authClient.validate(
  45. request,
  46. metadata,
  47. (error: grpc.ServiceError | null, response: SessionValidationResponse) => {
  48. if (error) {
  49. const responseCode = error.metadata.get('x-auth-error-response-code').pop()
  50. if (responseCode) {
  51. return resolve({
  52. status: +responseCode,
  53. data: {
  54. error: {
  55. message: error.metadata.get('x-auth-error-message').pop(),
  56. tag: error.metadata.get('x-auth-error-tag').pop(),
  57. },
  58. },
  59. headers: {
  60. contentType: 'application/json',
  61. },
  62. })
  63. }
  64. return reject(error)
  65. }
  66. return resolve({
  67. status: 200,
  68. data: {
  69. authToken: response.getCrossServiceToken(),
  70. },
  71. headers: {
  72. contentType: 'application/json',
  73. },
  74. })
  75. },
  76. )
  77. } catch (error) {
  78. return reject(error)
  79. }
  80. })
  81. try {
  82. const result = await promise
  83. if (retryAttempt) {
  84. this.logger.debug(`Request to Auth Server succeeded after ${retryAttempt} retries`)
  85. }
  86. return result as { status: number; data: unknown; headers: { contentType: string } }
  87. } catch (error) {
  88. const requestDidNotMakeIt =
  89. 'code' in (error as Record<string, unknown>) && (error as Record<string, unknown>).code === Status.UNAVAILABLE
  90. const tooManyRetryAttempts = retryAttempt && retryAttempt > 2
  91. if (!tooManyRetryAttempts && requestDidNotMakeIt) {
  92. await this.timer.sleep(50)
  93. const nextRetryAttempt = retryAttempt ? retryAttempt + 1 : 1
  94. this.logger.debug(`Retrying request to Auth Server for the ${nextRetryAttempt} time`)
  95. return this.validateSession(headers, nextRetryAttempt)
  96. }
  97. throw error
  98. }
  99. }
  100. async callSyncingServer(
  101. request: Request,
  102. response: Response,
  103. endpoint: string,
  104. payload?: Record<string, unknown> | string,
  105. ): Promise<void> {
  106. const requestIsUsingLatestApiVersions =
  107. payload !== undefined && typeof payload !== 'string' && 'api' in payload && payload.api === '20200115'
  108. if (requestIsUsingLatestApiVersions && endpoint === 'items/sync') {
  109. await this.callSyncingServerGRPC(request, response, payload)
  110. return
  111. }
  112. await this.callServer(this.syncingServerJsUrl, request, response, endpoint, payload)
  113. }
  114. private async callSyncingServerGRPC(
  115. request: Request,
  116. response: Response,
  117. payload?: Record<string, unknown> | string,
  118. ): Promise<void> {
  119. const locals = response.locals as ResponseLocals
  120. const result = await this.gRPCSyncingServerServiceProxy.sync(request, response, payload)
  121. response.status(result.status).send({
  122. meta: {
  123. auth: {
  124. userUuid: locals.user?.uuid,
  125. roles: locals.roles,
  126. },
  127. server: {
  128. filesServerUrl: this.filesServerUrl,
  129. },
  130. },
  131. data: result.data,
  132. })
  133. }
  134. async callRevisionsServer(
  135. request: Request,
  136. response: Response,
  137. endpoint: string,
  138. payload?: Record<string, unknown> | string,
  139. ): Promise<void> {
  140. if (!this.revisionsServerUrl) {
  141. response.status(400).send({ message: 'Revisions Server not configured' })
  142. return
  143. }
  144. await this.callServer(this.revisionsServerUrl, request, response, endpoint, payload)
  145. }
  146. async callLegacySyncingServer(
  147. request: Request,
  148. response: Response,
  149. endpoint: string,
  150. payload?: Record<string, unknown> | string,
  151. ): Promise<void> {
  152. await this.callServerWithLegacyFormat(this.syncingServerJsUrl, request, response, endpoint, payload)
  153. }
  154. async callAuthServer(
  155. request: Request,
  156. response: Response,
  157. endpoint: string,
  158. payload?: Record<string, unknown> | string,
  159. ): Promise<void> {
  160. await this.callServer(this.authServerUrl, request, response, endpoint, payload)
  161. }
  162. async callEmailServer(
  163. request: Request,
  164. response: Response,
  165. endpoint: string,
  166. payload?: Record<string, unknown> | string,
  167. ): Promise<void> {
  168. if (!this.emailServerUrl) {
  169. response.status(400).send({ message: 'Email Server not configured' })
  170. return
  171. }
  172. await this.callServer(this.emailServerUrl, request, response, endpoint, payload)
  173. }
  174. async callWebSocketServer(
  175. request: Request,
  176. response: Response,
  177. endpoint: string,
  178. payload?: Record<string, unknown> | string,
  179. ): Promise<void> {
  180. if (!this.webSocketServerUrl) {
  181. this.logger.debug('Websockets Server URL not defined. Skipped request to WebSockets API.')
  182. return
  183. }
  184. const isARequestComingFromApiGatewayAndShouldBeKeptInMinimalFormat = request.headers.connectionid !== undefined
  185. if (isARequestComingFromApiGatewayAndShouldBeKeptInMinimalFormat) {
  186. await this.callServerWithLegacyFormat(this.webSocketServerUrl, request, response, endpoint, payload)
  187. } else {
  188. await this.callServer(this.webSocketServerUrl, request, response, endpoint, payload)
  189. }
  190. }
  191. async callPaymentsServer(
  192. request: Request,
  193. response: Response,
  194. endpoint: string,
  195. payload?: Record<string, unknown> | string,
  196. ): Promise<void | Response<unknown, Record<string, unknown>>> {
  197. if (!this.paymentsServerUrl) {
  198. this.logger.debug('Payments Server URL not defined. Skipped request to Payments API.')
  199. return
  200. }
  201. await this.callServerWithLegacyFormat(this.paymentsServerUrl, request, response, endpoint, payload)
  202. }
  203. async callAuthServerWithLegacyFormat(
  204. request: Request,
  205. response: Response,
  206. endpoint: string,
  207. payload?: Record<string, unknown> | string,
  208. ): Promise<void> {
  209. await this.callServerWithLegacyFormat(this.authServerUrl, request, response, endpoint, payload)
  210. }
  211. private async getServerResponse(
  212. serverUrl: string,
  213. request: Request,
  214. response: Response,
  215. endpoint: string,
  216. payload?: Record<string, unknown> | string,
  217. retryAttempt?: number,
  218. ): Promise<AxiosResponse | undefined> {
  219. const locals = response.locals as ResponseLocals | OfflineResponseLocals
  220. try {
  221. const headers: Record<string, string> = {}
  222. for (const headerName of Object.keys(request.headers)) {
  223. headers[headerName] = request.headers[headerName] as string
  224. }
  225. delete headers.host
  226. delete headers['content-length']
  227. if ('authToken' in locals && locals.authToken) {
  228. headers['X-Auth-Token'] = locals.authToken
  229. }
  230. if ('offlineAuthToken' in locals && locals.offlineAuthToken) {
  231. headers['X-Auth-Offline-Token'] = locals.offlineAuthToken
  232. }
  233. const serviceResponse = await this.httpClient.request({
  234. method: request.method as Method,
  235. headers,
  236. url: `${serverUrl}/${endpoint}`,
  237. data: this.getRequestData(payload),
  238. maxContentLength: Infinity,
  239. maxBodyLength: Infinity,
  240. params: request.query,
  241. timeout: this.httpCallTimeout,
  242. validateStatus: (status: number) => {
  243. return status >= 200 && status < 500
  244. },
  245. })
  246. if (serviceResponse.headers['x-invalidate-cache']) {
  247. const userUuid = serviceResponse.headers['x-invalidate-cache']
  248. await this.crossServiceTokenCache.invalidate(userUuid)
  249. }
  250. if (retryAttempt) {
  251. this.logger.debug(`Request to ${serverUrl}/${endpoint} succeeded after ${retryAttempt} retries`)
  252. }
  253. return serviceResponse
  254. } catch (error) {
  255. const requestDidNotMakeIt = this.requestTimedOutOrDidNotReachDestination(error as Record<string, unknown>)
  256. const tooManyRetryAttempts = retryAttempt && retryAttempt > 2
  257. if (!tooManyRetryAttempts && requestDidNotMakeIt) {
  258. await this.timer.sleep(50)
  259. const nextRetryAttempt = retryAttempt ? retryAttempt + 1 : 1
  260. this.logger.debug(`Retrying request to ${serverUrl}/${endpoint} for the ${nextRetryAttempt} time`)
  261. return this.getServerResponse(serverUrl, request, response, endpoint, payload, nextRetryAttempt)
  262. }
  263. let detailedErrorMessage = (error as Error).message
  264. if (error instanceof AxiosError) {
  265. detailedErrorMessage = `Status: ${error.status}, code: ${error.code}, message: ${error.message}`
  266. }
  267. this.logger.error(
  268. tooManyRetryAttempts
  269. ? `Request to ${serverUrl}/${endpoint} timed out after ${retryAttempt} retries`
  270. : `Could not pass the request to ${serverUrl}/${endpoint} on underlying service: ${detailedErrorMessage}`,
  271. {
  272. userId: (locals as ResponseLocals).user ? (locals as ResponseLocals).user.uuid : undefined,
  273. },
  274. )
  275. this.logger.debug(`Response error: ${JSON.stringify(error)}`)
  276. if ((error as AxiosError).response?.headers['content-type']) {
  277. response.setHeader('content-type', (error as AxiosError).response?.headers['content-type'] as string)
  278. }
  279. const errorCode =
  280. (error as AxiosError).isAxiosError && !isNaN(+((error as AxiosError).code as string))
  281. ? +((error as AxiosError).code as string)
  282. : 500
  283. const responseErrorMessage = (error as AxiosError).response?.data
  284. response
  285. .status(errorCode)
  286. .send(
  287. responseErrorMessage ??
  288. "Unfortunately, we couldn't handle your request. Please try again or contact our support if the error persists.",
  289. )
  290. }
  291. return
  292. }
  293. private async callServer(
  294. serverUrl: string,
  295. request: Request,
  296. response: Response,
  297. endpoint: string,
  298. payload?: Record<string, unknown> | string,
  299. ): Promise<void> {
  300. const locals = response.locals as ResponseLocals
  301. const serviceResponse = await this.getServerResponse(serverUrl, request, response, endpoint, payload)
  302. if (!serviceResponse) {
  303. return
  304. }
  305. this.applyResponseHeaders(serviceResponse, response)
  306. if (this.responseShouldNotBeDecorated(serviceResponse)) {
  307. response.status(serviceResponse.status).send(serviceResponse.data)
  308. return
  309. }
  310. response.status(serviceResponse.status).send({
  311. meta: {
  312. auth: {
  313. userUuid: locals.user?.uuid,
  314. roles: locals.roles,
  315. },
  316. server: {
  317. filesServerUrl: this.filesServerUrl,
  318. },
  319. },
  320. data: serviceResponse.data,
  321. })
  322. }
  323. private async callServerWithLegacyFormat(
  324. serverUrl: string,
  325. request: Request,
  326. response: Response,
  327. endpoint: string,
  328. payload?: Record<string, unknown> | string,
  329. ): Promise<void | Response<unknown, Record<string, unknown>>> {
  330. const serviceResponse = await this.getServerResponse(serverUrl, request, response, endpoint, payload)
  331. if (!serviceResponse) {
  332. return
  333. }
  334. this.applyResponseHeaders(serviceResponse, response)
  335. if (serviceResponse.request._redirectable._redirectCount > 0) {
  336. response.status(302)
  337. response.redirect(serviceResponse.request.res.responseUrl)
  338. } else {
  339. response.status(serviceResponse.status)
  340. response.send(serviceResponse.data)
  341. }
  342. }
  343. private getRequestData(
  344. payload: Record<string, unknown> | string | undefined,
  345. ): Record<string, unknown> | string | undefined {
  346. if (
  347. payload === '' ||
  348. payload === null ||
  349. payload === undefined ||
  350. (typeof payload === 'object' && Object.keys(payload).length === 0)
  351. ) {
  352. return undefined
  353. }
  354. return payload
  355. }
  356. private responseShouldNotBeDecorated(serviceResponse: AxiosResponse): boolean {
  357. return (
  358. serviceResponse.headers['content-type'] !== undefined &&
  359. serviceResponse.headers['content-type'].toLowerCase().includes('text/html')
  360. )
  361. }
  362. private applyResponseHeaders(serviceResponse: AxiosResponse, response: Response): void {
  363. const returnedHeadersFromUnderlyingService = [
  364. 'access-control-allow-methods',
  365. 'access-control-allow-origin',
  366. 'access-control-expose-headers',
  367. 'authorization',
  368. 'content-type',
  369. 'x-ssjs-version',
  370. 'x-auth-version',
  371. ]
  372. returnedHeadersFromUnderlyingService.map((headerName) => {
  373. const headerValue = serviceResponse.headers[headerName]
  374. if (headerValue) {
  375. response.setHeader(headerName, headerValue)
  376. }
  377. })
  378. }
  379. private requestTimedOutOrDidNotReachDestination(error: Record<string, unknown>): boolean {
  380. return (
  381. ('code' in error && error.code === 'ETIMEDOUT') ||
  382. ('response' in error &&
  383. 'status' in (error.response as Record<string, unknown>) &&
  384. [503, 504].includes((error.response as Record<string, unknown>).status as number))
  385. )
  386. }
  387. }