Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(auto-refresh-fetch): enable auto refresh and fetch oauth token flow #162

Merged
merged 3 commits into from
Apr 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
}
Loading