From ea353fea29c39fb43c717a21917ef309d0e68806 Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Tue, 9 Apr 2024 06:25:20 +0500 Subject: [PATCH 1/3] feat(auto-refresh-fetch): enable auto refresh and fetch oauth token flow --- packages/oauth-adapters/package.json | 2 +- packages/oauth-adapters/src/oAuthToken.ts | 11 +- .../src/oauthAuthenticationAdapter.ts | 49 ++-- .../test/oauthAuthenticationAdapter.test.ts | 239 +++++++++++------- 4 files changed, 187 insertions(+), 114 deletions(-) diff --git a/packages/oauth-adapters/package.json b/packages/oauth-adapters/package.json index 0da54c3f..6e2c4c09 100644 --- a/packages/oauth-adapters/package.json +++ b/packages/oauth-adapters/package.json @@ -1,7 +1,7 @@ { "name": "@apimatic/oauth-adapters", "author": "APIMatic Ltd.", - "version": "0.4.0", + "version": "0.4.1", "license": "MIT", "sideEffects": false, "main": "lib/index.js", diff --git a/packages/oauth-adapters/src/oAuthToken.ts b/packages/oauth-adapters/src/oAuthToken.ts index 87e122ef..0e54bdad 100644 --- a/packages/oauth-adapters/src/oAuthToken.ts +++ b/packages/oauth-adapters/src/oAuthToken.ts @@ -1,4 +1,10 @@ -import { bigint, object, optional, Schema, string } from '@apimatic/schema'; +import { + bigint, + expandoObject, + optional, + Schema, + string, +} from '@apimatic/schema'; /** OAuth 2 Authorization endpoint response */ export interface OAuthToken { @@ -20,9 +26,10 @@ export interface OAuthToken { * Used to get a new access token when it expires. */ refreshToken?: string; + [key: string]: unknown; } -export const oAuthTokenSchema: Schema = object({ +export const oAuthTokenSchema: Schema = expandoObject({ accessToken: ['access_token', string()], tokenType: ['token_type', string()], expiresIn: ['expires_in', optional(bigint())], diff --git a/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts b/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts index 0f0c936f..1f3a9080 100644 --- a/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts +++ b/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts @@ -6,15 +6,45 @@ import { import { AUTHORIZATION_HEADER, setHeader } from '@apimatic/http-headers'; export const requestAuthenticationProvider = ( - oAuthToken?: OAuthToken + initialOAuthToken?: OAuthToken, + oAuthTokenProvider?: (token: OAuthToken | undefined) => Promise, + oAuthOnTokenUpdate?: (token: OAuthToken) => void ): AuthenticatorInterface => { + // This token is shared between all API calls for a client instance. + let lastOAuthToken: Promise = Promise.resolve( + initialOAuthToken + ); + return (requiresAuth?: boolean) => { if (!requiresAuth) { return passThroughInterceptor; } - validateAuthorization(oAuthToken); - return createRequestInterceptor(oAuthToken); + return async (request: any, options: any, next: any) => { + let oAuthToken = await lastOAuthToken; + + if ( + oAuthTokenProvider && + (!isValid(oAuthToken) || isExpired(oAuthToken)) + ) { + // Set the shared token for the next API calls to use. + lastOAuthToken = oAuthTokenProvider(oAuthToken); + oAuthToken = await lastOAuthToken; + if (oAuthOnTokenUpdate && oAuthToken) { + oAuthOnTokenUpdate(oAuthToken); + } + } + + validateAuthorization(oAuthToken); + request.headers = request.headers ?? {}; + setHeader( + request.headers, + AUTHORIZATION_HEADER, + `Bearer ${oAuthToken?.accessToken}` + ); + + return next(request, options); + }; }; }; @@ -32,19 +62,6 @@ function validateAuthorization(oAuthToken?: OAuthToken) { } } -function createRequestInterceptor(oAuthToken?: OAuthToken) { - return (request: any, options: any, next: any) => { - request.headers = request.headers ?? {}; - setHeader( - request.headers, - AUTHORIZATION_HEADER, - `Bearer ${oAuthToken?.accessToken}` - ); - - return next(request, options); - }; -} - function isValid(oAuthToken: OAuthToken | undefined): oAuthToken is OAuthToken { return typeof oAuthToken !== 'undefined'; } diff --git a/packages/oauth-adapters/test/oauthAuthenticationAdapter.test.ts b/packages/oauth-adapters/test/oauthAuthenticationAdapter.test.ts index cc604b8a..1027dcb8 100644 --- a/packages/oauth-adapters/test/oauthAuthenticationAdapter.test.ts +++ b/packages/oauth-adapters/test/oauthAuthenticationAdapter.test.ts @@ -2,24 +2,27 @@ import { callHttpInterceptors } from '../../core/src/http/httpInterceptor'; import { requestAuthenticationProvider } from '../src/oauthAuthenticationAdapter'; import { HttpContext, + HttpInterceptorInterface, HttpRequest, HttpResponse, + RequestOptions, } from '../../core-interfaces/src'; import { OAuthToken } from '../src/oAuthToken'; describe('test oauth request provider', () => { - it('should test oauth request provider with enabled authentication', async () => { - const response: HttpResponse = { - statusCode: 200, - body: 'testBody', - headers: { 'test-header': 'test-value' }, - }; - - const request: HttpRequest = { - method: 'GET', - url: 'http://apimatic.hopto.org:3000/test/requestBuilder', + it('should pass with disabled authentication', async () => { + const oAuthToken = { + accessToken: '1f12495f1a1ad9066b51fb3b4e456aee', + tokenType: 'Bearer', + expiresIn: BigInt(100000), + scope: '[products, orders]', + expiry: BigInt(Date.now()), }; + const authenticationProvider = requestAuthenticationProvider(oAuthToken); + return await executeAndExpect(authenticationProvider(false), undefined); + }); + it('should pass with valid token', async () => { const oAuthToken: OAuthToken = { accessToken: '1f12495f1a1ad9066b51fb3b4e456aee', tokenType: 'Bearer', @@ -28,62 +31,83 @@ describe('test oauth request provider', () => { expiry: BigInt(Date.now()), }; const authenticationProvider = requestAuthenticationProvider(oAuthToken); - let context: HttpContext = { request, response }; - const handler = authenticationProvider(true); - const interceptor = [handler]; - const client = async (req) => { - return { request: req, response }; - }; - const executor = callHttpInterceptors(interceptor, client); - context = await executor(request, undefined); - return expect(context.request.headers).toEqual({ + return await executeAndExpect(authenticationProvider(true), { authorization: 'Bearer 1f12495f1a1ad9066b51fb3b4e456aee', }); }); - it('should test oauth request provider with disabled authentication', async () => { - const response: HttpResponse = { - statusCode: 200, - body: 'testBody', - headers: { 'test-header': 'test-value' }, - }; - - const request: HttpRequest = { - method: 'GET', - url: 'http://apimatic.hopto.org:3000/test/requestBuilder', - }; - - const oAuthToken = { + it('should pass with valid token + authProvider + updateCallback', async () => { + const oAuthToken: OAuthToken = { accessToken: '1f12495f1a1ad9066b51fb3b4e456aee', tokenType: 'Bearer', expiresIn: BigInt(100000), scope: '[products, orders]', expiry: BigInt(Date.now()), }; - const authenticationProvider = requestAuthenticationProvider(oAuthToken); - let context: HttpContext = { request, response }; - const handler = authenticationProvider(false); - const interceptor = [handler]; - const client = async (req) => { - return { request: req, response }; - }; - const executor = callHttpInterceptors(interceptor, client); - context = await executor(request, undefined); - return expect(context.request.headers).toBeUndefined(); + const authenticationProvider = requestAuthenticationProvider( + oAuthToken, + (token: OAuthToken | undefined) => { + // return an invalid token if accessed from provider + if (token === undefined) { + return Promise.resolve({ + accessToken: 'Invalid', + tokenType: 'Bearer', + }); + } + return Promise.resolve({ + ...token, + accessToken: 'Invalid', + }); + }, + (_: OAuthToken) => { + // fail if token gets updated + expect(true).toBe(false); + } + ); + return await executeAndExpect(authenticationProvider(true), { + authorization: 'Bearer 1f12495f1a1ad9066b51fb3b4e456aee', + }); }); - it('should test oauth request provider with enabled authentication and expired token', async () => { - const response: HttpResponse = { - statusCode: 200, - body: 'testBody', - headers: { 'test-header': 'test-value' }, - }; + it('should fail with undefined token', async () => { + const authenticationProvider = requestAuthenticationProvider(); + return await executeAndExpect( + authenticationProvider(true), + undefined, + 'Client is not authorized. An OAuth token is needed to make API calls.' + ); + }); - const request: HttpRequest = { - method: 'GET', - url: 'http://apimatic.hopto.org:3000/test/requestBuilder', - }; + it('should pass with undefined token + authProvider + updateCallback', async () => { + const authenticationProvider = requestAuthenticationProvider( + undefined, + (token: OAuthToken | undefined) => { + if (token === undefined) { + return Promise.resolve({ + accessToken: '1f12495f1a1ad9066b51fb3b4e456aee', + tokenType: 'Bearer', + expiresIn: BigInt(100000), + scope: '[products, orders]', + expiry: BigInt(Date.now()), + }); + } + // return an invalid token if existing token is not undefined + return Promise.resolve({ + ...token, + accessToken: 'Invalid', + }); + }, + (token: OAuthToken) => { + // check the updated token + expect(token.accessToken).toBe('1f12495f1a1ad9066b51fb3b4e456aee'); + } + ); + return await executeAndExpect(authenticationProvider(true), { + authorization: 'Bearer 1f12495f1a1ad9066b51fb3b4e456aee', + }); + }); + it('should fail with expired token', async () => { const oAuthToken = { accessToken: '1f12495f1a1ad9066b51fb3b4e456aee', tokenType: 'Bearer', @@ -91,52 +115,77 @@ describe('test oauth request provider', () => { scope: '[products, orders]', expiry: BigInt(2000), }; - try { - const authenticationProvider = requestAuthenticationProvider(oAuthToken); - const handler = authenticationProvider(true); - const interceptor = [handler]; - const client = async (req) => { - return { request: req, response }; - }; - const executor = callHttpInterceptors(interceptor, client); - const context = await executor(request, undefined); - expect(context.request.headers).toBeUndefined(); - } catch (error) { - const { message } = error as Error; - expect(message).toEqual( - 'OAuth token is expired. A valid token is needed to make API calls.' - ); - } + const authenticationProvider = requestAuthenticationProvider(oAuthToken); + return await executeAndExpect( + authenticationProvider(true), + undefined, + 'OAuth token is expired. A valid token is needed to make API calls.' + ); }); - it('should test oauth request provider with enabled authentication and undefined token', async () => { - const response: HttpResponse = { - statusCode: 200, - body: 'testBody', - headers: { 'test-header': 'test-value' }, - }; - - const request: HttpRequest = { - method: 'GET', - url: 'http://apimatic.hopto.org:3000/test/requestBuilder', + it('should pass with expired token + authProvider + updateCallback', async () => { + const oAuthToken = { + accessToken: '1f12495f1a1ad9066b51fb3b4e456aee', + tokenType: 'Bearer', + expiresIn: BigInt(100000), + scope: '[products, orders]', + expiry: BigInt(2000), }; - try { - const authenticationProvider = requestAuthenticationProvider(); - let context: HttpContext = { request, response }; - const handler = authenticationProvider(true); - const interceptor = [handler]; - const client = async (req) => { - return { request: req, response }; - }; - const executor = callHttpInterceptors(interceptor, client); - context = await executor(request, undefined); - expect(context.request.headers).toBeUndefined(); - } catch (error) { - const { message } = error as Error; - expect(message).toEqual( - 'Client is not authorized. An OAuth token is needed to make API calls.' - ); - } + const authenticationProvider = requestAuthenticationProvider( + oAuthToken, + (token: OAuthToken | undefined) => { + if (token === undefined) { + // return an invalid token if existing token is undefined + return Promise.resolve({ + accessToken: 'Invalid', + tokenType: 'Bearer', + }); + } + return Promise.resolve({ + ...token, + accessToken: '1f12495f1a1ad9066b51fb3b4e456aeeNEW', + expiry: BigInt(Date.now()), + }); + }, + (token: OAuthToken) => { + // check the updated token + expect(token.accessToken).toBe('1f12495f1a1ad9066b51fb3b4e456aeeNEW'); + } + ); + return await executeAndExpect(authenticationProvider(true), { + authorization: 'Bearer 1f12495f1a1ad9066b51fb3b4e456aeeNEW', + }); }); }); + +async function executeAndExpect( + authenticationProvider: HttpInterceptorInterface, + headers: Record | undefined, + errorMessage?: string +) { + const response: HttpResponse = { + statusCode: 200, + body: 'testBody', + headers: { 'test-header': 'test-value' }, + }; + + const request: HttpRequest = { + method: 'GET', + url: 'http://apimatic.hopto.org:3000/test/requestBuilder', + }; + let context: HttpContext = { request, response }; + try { + const interceptor = [authenticationProvider]; + const client = async (req: any) => { + return { request: req, response }; + }; + const executor = callHttpInterceptors(interceptor, client); + context = await executor(request, undefined); + expect(context.request.headers).toEqual(headers); + } catch (error) { + expect(errorMessage === undefined).toBe(false); + const { message } = error as Error; + expect(message).toEqual(errorMessage); + } +} From 6a4e84fa155fe49b5078d682f240ee7f6b8fde29 Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Tue, 9 Apr 2024 06:36:54 +0500 Subject: [PATCH 2/3] refactored requestAuthenticationProvider --- .../src/oauthAuthenticationAdapter.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts b/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts index 1f3a9080..effbce17 100644 --- a/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts +++ b/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts @@ -22,7 +22,6 @@ export const requestAuthenticationProvider = ( return async (request: any, options: any, next: any) => { let oAuthToken = await lastOAuthToken; - if ( oAuthTokenProvider && (!isValid(oAuthToken) || isExpired(oAuthToken)) @@ -34,20 +33,25 @@ export const requestAuthenticationProvider = ( oAuthOnTokenUpdate(oAuthToken); } } - - validateAuthorization(oAuthToken); - request.headers = request.headers ?? {}; - setHeader( - request.headers, - AUTHORIZATION_HEADER, - `Bearer ${oAuthToken?.accessToken}` - ); - + setOAuthTokenInRequest(oAuthToken, request); return next(request, options); }; }; }; +function setOAuthTokenInRequest( + oAuthToken: OAuthToken | undefined, + request: any +) { + validateAuthorization(oAuthToken); + request.headers = request.headers ?? {}; + setHeader( + request.headers, + AUTHORIZATION_HEADER, + `Bearer ${oAuthToken?.accessToken}` + ); +} + function validateAuthorization(oAuthToken?: OAuthToken) { if (!isValid(oAuthToken)) { throw new Error( From 84402f286f613cb0e11b778cd86d27dda1e3312d Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Tue, 9 Apr 2024 14:23:02 +0500 Subject: [PATCH 3/3] added suggested changes --- packages/oauth-adapters/src/oAuthToken.ts | 11 +- .../src/oauthAuthenticationAdapter.ts | 2 +- .../test/oauthAuthenticationAdapter.test.ts | 136 ++++++++++-------- 3 files changed, 77 insertions(+), 72 deletions(-) diff --git a/packages/oauth-adapters/src/oAuthToken.ts b/packages/oauth-adapters/src/oAuthToken.ts index 0e54bdad..87e122ef 100644 --- a/packages/oauth-adapters/src/oAuthToken.ts +++ b/packages/oauth-adapters/src/oAuthToken.ts @@ -1,10 +1,4 @@ -import { - bigint, - expandoObject, - optional, - Schema, - string, -} from '@apimatic/schema'; +import { bigint, object, optional, Schema, string } from '@apimatic/schema'; /** OAuth 2 Authorization endpoint response */ export interface OAuthToken { @@ -26,10 +20,9 @@ export interface OAuthToken { * Used to get a new access token when it expires. */ refreshToken?: string; - [key: string]: unknown; } -export const oAuthTokenSchema: Schema = expandoObject({ +export const oAuthTokenSchema: Schema = object({ accessToken: ['access_token', string()], tokenType: ['token_type', string()], expiresIn: ['expires_in', optional(bigint())], diff --git a/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts b/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts index effbce17..316e94ee 100644 --- a/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts +++ b/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts @@ -20,7 +20,7 @@ export const requestAuthenticationProvider = ( return passThroughInterceptor; } - return async (request: any, options: any, next: any) => { + return async (request, options, next) => { let oAuthToken = await lastOAuthToken; if ( oAuthTokenProvider && diff --git a/packages/oauth-adapters/test/oauthAuthenticationAdapter.test.ts b/packages/oauth-adapters/test/oauthAuthenticationAdapter.test.ts index 1027dcb8..0c8c3abd 100644 --- a/packages/oauth-adapters/test/oauthAuthenticationAdapter.test.ts +++ b/packages/oauth-adapters/test/oauthAuthenticationAdapter.test.ts @@ -15,7 +15,7 @@ describe('test oauth request provider', () => { accessToken: '1f12495f1a1ad9066b51fb3b4e456aee', tokenType: 'Bearer', expiresIn: BigInt(100000), - scope: '[products, orders]', + scope: 'products orders', expiry: BigInt(Date.now()), }; const authenticationProvider = requestAuthenticationProvider(oAuthToken); @@ -27,7 +27,7 @@ describe('test oauth request provider', () => { accessToken: '1f12495f1a1ad9066b51fb3b4e456aee', tokenType: 'Bearer', expiresIn: BigInt(100000), - scope: '[products, orders]', + scope: 'products orders', expiry: BigInt(Date.now()), }; const authenticationProvider = requestAuthenticationProvider(oAuthToken); @@ -41,32 +41,28 @@ describe('test oauth request provider', () => { accessToken: '1f12495f1a1ad9066b51fb3b4e456aee', tokenType: 'Bearer', expiresIn: BigInt(100000), - scope: '[products, orders]', + scope: 'products orders', expiry: BigInt(Date.now()), }; + const oAuthTokenProvider = jest.fn((_: OAuthToken | undefined) => { + return Promise.resolve({ + accessToken: 'Invalid', + tokenType: 'Bearer', + }); + }); + const oAuthOnTokenUpdate = jest.fn((_: OAuthToken) => { + // handler for updated token + }); const authenticationProvider = requestAuthenticationProvider( oAuthToken, - (token: OAuthToken | undefined) => { - // return an invalid token if accessed from provider - if (token === undefined) { - return Promise.resolve({ - accessToken: 'Invalid', - tokenType: 'Bearer', - }); - } - return Promise.resolve({ - ...token, - accessToken: 'Invalid', - }); - }, - (_: OAuthToken) => { - // fail if token gets updated - expect(true).toBe(false); - } + oAuthTokenProvider, + oAuthOnTokenUpdate ); - return await executeAndExpect(authenticationProvider(true), { + await executeAndExpect(authenticationProvider(true), { authorization: 'Bearer 1f12495f1a1ad9066b51fb3b4e456aee', }); + expect(oAuthTokenProvider.mock.calls).toHaveLength(0); + expect(oAuthOnTokenUpdate.mock.calls).toHaveLength(0); }); it('should fail with undefined token', async () => { @@ -79,32 +75,39 @@ describe('test oauth request provider', () => { }); it('should pass with undefined token + authProvider + updateCallback', async () => { - const authenticationProvider = requestAuthenticationProvider( - undefined, - (token: OAuthToken | undefined) => { - if (token === undefined) { - return Promise.resolve({ - accessToken: '1f12495f1a1ad9066b51fb3b4e456aee', - tokenType: 'Bearer', - expiresIn: BigInt(100000), - scope: '[products, orders]', - expiry: BigInt(Date.now()), - }); - } - // return an invalid token if existing token is not undefined + const oAuthTokenProvider = jest.fn((token: OAuthToken | undefined) => { + if (token === undefined) { return Promise.resolve({ - ...token, - accessToken: 'Invalid', + accessToken: '1f12495f1a1ad9066b51fb3b4e456aee', + tokenType: 'Bearer', + expiresIn: BigInt(100000), + scope: 'products orders', + expiry: BigInt(Date.now()), }); - }, - (token: OAuthToken) => { - // check the updated token - expect(token.accessToken).toBe('1f12495f1a1ad9066b51fb3b4e456aee'); } + // return an invalid token if existing token is not undefined + return Promise.resolve({ + ...token, + accessToken: 'Invalid', + }); + }); + const oAuthOnTokenUpdate = jest.fn((_: OAuthToken) => { + // handler for updated token + }); + const authenticationProvider = requestAuthenticationProvider( + undefined, + oAuthTokenProvider, + oAuthOnTokenUpdate ); - return await executeAndExpect(authenticationProvider(true), { + await executeAndExpect(authenticationProvider(true), { authorization: 'Bearer 1f12495f1a1ad9066b51fb3b4e456aee', }); + expect(oAuthTokenProvider.mock.calls).toHaveLength(1); + expect(oAuthTokenProvider.mock.calls[0][0]).toBe(undefined); + expect(oAuthOnTokenUpdate.mock.calls).toHaveLength(1); + expect(oAuthOnTokenUpdate.mock.calls[0][0].accessToken).toBe( + '1f12495f1a1ad9066b51fb3b4e456aee' + ); }); it('should fail with expired token', async () => { @@ -112,7 +115,7 @@ describe('test oauth request provider', () => { accessToken: '1f12495f1a1ad9066b51fb3b4e456aee', tokenType: 'Bearer', expiresIn: BigInt(100000), - scope: '[products, orders]', + scope: 'products orders', expiry: BigInt(2000), }; const authenticationProvider = requestAuthenticationProvider(oAuthToken); @@ -128,34 +131,43 @@ describe('test oauth request provider', () => { accessToken: '1f12495f1a1ad9066b51fb3b4e456aee', tokenType: 'Bearer', expiresIn: BigInt(100000), - scope: '[products, orders]', + scope: 'products orders', expiry: BigInt(2000), }; - - const authenticationProvider = requestAuthenticationProvider( - oAuthToken, - (token: OAuthToken | undefined) => { - if (token === undefined) { - // return an invalid token if existing token is undefined - return Promise.resolve({ - accessToken: 'Invalid', - tokenType: 'Bearer', - }); - } + const oAuthTokenProvider = jest.fn((token: OAuthToken | undefined) => { + if (token === undefined) { + // return an invalid token if existing token is undefined return Promise.resolve({ - ...token, - accessToken: '1f12495f1a1ad9066b51fb3b4e456aeeNEW', - expiry: BigInt(Date.now()), + accessToken: 'Invalid', + tokenType: 'Bearer', }); - }, - (token: OAuthToken) => { - // check the updated token - expect(token.accessToken).toBe('1f12495f1a1ad9066b51fb3b4e456aeeNEW'); } + return Promise.resolve({ + ...token, + accessToken: '1f12495f1a1ad9066b51fb3b4e456aeeNEW', + expiry: BigInt(Date.now()), + }); + }); + const oAuthOnTokenUpdate = jest.fn((_: OAuthToken) => { + // handler for updated token + }); + + const authenticationProvider = requestAuthenticationProvider( + oAuthToken, + oAuthTokenProvider, + oAuthOnTokenUpdate ); - return await executeAndExpect(authenticationProvider(true), { + await executeAndExpect(authenticationProvider(true), { authorization: 'Bearer 1f12495f1a1ad9066b51fb3b4e456aeeNEW', }); + expect(oAuthTokenProvider.mock.calls).toHaveLength(1); + expect(oAuthTokenProvider.mock.calls[0][0]?.accessToken).toBe( + '1f12495f1a1ad9066b51fb3b4e456aee' + ); + expect(oAuthOnTokenUpdate.mock.calls).toHaveLength(1); + expect(oAuthOnTokenUpdate.mock.calls[0][0].accessToken).toBe( + '1f12495f1a1ad9066b51fb3b4e456aeeNEW' + ); }); });