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 2 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
11 changes: 9 additions & 2 deletions packages/oauth-adapters/src/oAuthToken.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<OAuthToken> = object({
export const oAuthTokenSchema: Schema<OAuthToken> = expandoObject({
accessToken: ['access_token', string()],
tokenType: ['token_type', string()],
expiresIn: ['expires_in', optional(bigint())],
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: 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);
}
}
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
239 changes: 144 additions & 95 deletions packages/oauth-adapters/test/oauthAuthenticationAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -28,115 +31,161 @@ 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',
expiresIn: BigInt(100000),
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<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