Skip to content

Commit

Permalink
feat(auto-refresh-fetch): enable auto refresh and fetch oauth token f…
Browse files Browse the repository at this point in the history
…low (#162)
  • Loading branch information
asadali214 authored Apr 9, 2024
1 parent e132c2c commit a8da229
Show file tree
Hide file tree
Showing 3 changed files with 197 additions and 115 deletions.
2 changes: 1 addition & 1 deletion packages/oauth-adapters/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
53 changes: 37 additions & 16 deletions packages/oauth-adapters/src/oauthAuthenticationAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OAuthToken>,
oAuthOnTokenUpdate?: (token: OAuthToken) => void
): AuthenticatorInterface<boolean> => {
// This token is shared between all API calls for a client instance.
let lastOAuthToken: Promise<OAuthToken | undefined> = 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(
Expand All @@ -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';
}
Expand Down
257 changes: 159 additions & 98 deletions packages/oauth-adapters/test/oauthAuthenticationAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RequestOptions | undefined>,
headers: Record<string, string> | 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);
}
}

0 comments on commit a8da229

Please sign in to comment.