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/oauthAuthenticationAdapter.ts b/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts index 0f0c936f..316e94ee 100644 --- a/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts +++ b/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts @@ -6,18 +6,52 @@ 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, options, next) => { + 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); + } + } + 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( @@ -32,19 +66,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..0c8c3abd 100644 --- a/packages/oauth-adapters/test/oauthAuthenticationAdapter.test.ts +++ b/packages/oauth-adapters/test/oauthAuthenticationAdapter.test.ts @@ -2,141 +2,202 @@ 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', expiresIn: BigInt(100000), - scope: '[products, orders]', + scope: 'products orders', 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]', + 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 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, + oAuthTokenProvider, + oAuthOnTokenUpdate + ); + await executeAndExpect(authenticationProvider(true), { + authorization: 'Bearer 1f12495f1a1ad9066b51fb3b4e456aee', + }); + expect(oAuthTokenProvider.mock.calls).toHaveLength(0); + expect(oAuthOnTokenUpdate.mock.calls).toHaveLength(0); }); - 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 oAuthTokenProvider = jest.fn((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', + }); + }); + const oAuthOnTokenUpdate = jest.fn((_: OAuthToken) => { + // handler for updated token + }); + const authenticationProvider = requestAuthenticationProvider( + undefined, + oAuthTokenProvider, + oAuthOnTokenUpdate + ); + 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 () => { const oAuthToken = { accessToken: '1f12495f1a1ad9066b51fb3b4e456aee', tokenType: 'Bearer', expiresIn: BigInt(100000), - scope: '[products, orders]', + 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), }; + const oAuthTokenProvider = jest.fn((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()), + }); + }); + const oAuthOnTokenUpdate = jest.fn((_: OAuthToken) => { + // handler for updated token + }); - 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, + oAuthTokenProvider, + oAuthOnTokenUpdate + ); + 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' + ); }); }); + +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); + } +}