Repository: apimatic/apimatic-js-runtime
Pull request #232: refactor(oauth): replace OAuthToken with generic type
Author: login: Ayeshas09
Created: 2025-05-09T13:02:16Z | Merged: 2025-05-12T12:11:04Z

This PR replaces the specific OAuthToken type with a generic type parameter T that extends OAuthTokenConstraints. This change enables SDKs to define and use their own token types while still conforming to the core OAuth functionality.


Diff:
diff --git a/packages/authentication-adapters/test/compositeAuthenticationAdapter.test.ts b/packages/authentication-adapters/test/compositeAuthenticationAdapter.test.ts
index 3655ef4f0..255850783 100644
--- a/packages/authentication-adapters/test/compositeAuthenticationAdapter.test.ts
+++ b/packages/authentication-adapters/test/compositeAuthenticationAdapter.test.ts
@@ -6,7 +6,6 @@ import { customHeaderAuthenticationProvider } from '../src/customHeaderAuthentic
 import { callHttpInterceptors } from '../../core/src/http/httpInterceptor';
 import { HttpRequest, HttpResponse } from '@apimatic/core-interfaces';
 import { requestAuthenticationProvider } from '../../oauth-adapters/src/oauthAuthenticationAdapter';
-import { OAuthToken } from '../../oauth-adapters/src/oAuthToken';
 
 interface Configuration {
   basicAuthCredentials?: {
@@ -30,14 +29,14 @@ interface Configuration {
   oAuthCCGCredentials?: {
     oAuthClientId: string;
     oAuthClientSecret: string;
-    oAuthToken?: OAuthToken;
+    oAuthToken?: any;
     oAuthScopes?: 'read' | 'write';
   };
   oAuthACGCredentials?: {
     oAuthClientId: string;
     oAuthClientSecret: string;
     oAuthRedirectUri: string;
-    oAuthToken?: OAuthToken;
+    oAuthToken?: any;
     oAuthScopes?: 'read' | 'write';
   };
   timeout: number;
diff --git a/packages/oauth-adapters/src/oAuthToken.ts b/packages/oauth-adapters/src/oAuthToken.ts
deleted file mode 100644
index 87e122eff..000000000
--- a/packages/oauth-adapters/src/oAuthToken.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { bigint, object, optional, Schema, string } from '@apimatic/schema';
-
-/** OAuth 2 Authorization endpoint response */
-export interface OAuthToken {
-  /** Access token */
-  accessToken: string;
-  /** Type of access token */
-  tokenType: string;
-  /** Time in seconds before the access token expires */
-  expiresIn?: bigint;
-  /**
-   * List of scopes granted
-   * This is a space-delimited list of strings.
-   */
-  scope?: string;
-  /** Time of token expiry as unix timestamp (UTC) */
-  expiry?: bigint;
-  /**
-   * Refresh token
-   * Used to get a new access token when it expires.
-   */
-  refreshToken?: string;
-}
-
-export const oAuthTokenSchema: Schema<OAuthToken> = object({
-  accessToken: ['access_token', string()],
-  tokenType: ['token_type', string()],
-  expiresIn: ['expires_in', optional(bigint())],
-  scope: ['scope', optional(string())],
-  expiry: ['expiry', optional(bigint())],
-  refreshToken: ['refresh_token', optional(string())],
-});
diff --git a/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts b/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts
index a10dbe814..51bc364f5 100644
--- a/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts
+++ b/packages/oauth-adapters/src/oauthAuthenticationAdapter.ts
@@ -1,4 +1,3 @@
-import { OAuthToken } from './oAuthToken';
 import {
   AuthenticatorInterface,
   passThroughInterceptor,
@@ -6,14 +5,20 @@ import {
 import { AUTHORIZATION_HEADER, setHeader } from '@apimatic/http-headers';
 import { OAuthConfiguration } from './oAuthConfiguration';
 
-export const requestAuthenticationProvider = (
-  initialOAuthToken?: OAuthToken,
-  oAuthTokenProvider?: (token: OAuthToken | undefined) => Promise<OAuthToken>,
-  oAuthOnTokenUpdate?: (token: OAuthToken) => void,
-  oAuthConfiguration?: OAuthConfiguration
+interface OAuthTokenConstraints {
+  accessToken?: string;
+  expiry?: bigint;
+}
+
+export const requestAuthenticationProvider = <T extends OAuthTokenConstraints>(
+  initialOAuthToken?: T,
+  oAuthTokenProvider?: (token: T | undefined) => Promise<T>,
+  oAuthOnTokenUpdate?: (token: T) => void,
+  oAuthConfiguration?: OAuthConfiguration,
+  setOAuthHeader?: (request: any, token: T) => void
 ): AuthenticatorInterface<boolean> => {
   // This token is shared between all API calls for a client instance.
-  let lastOAuthToken: Promise<OAuthToken | undefined> = Promise.resolve(
+  let lastOAuthToken: Promise<T | undefined> = Promise.resolve(
     initialOAuthToken
   );
 
@@ -21,38 +26,58 @@ export const requestAuthenticationProvider = (
     if (!requiresAuth) {
       return passThroughInterceptor;
     }
-
     return async (request, options, next) => {
-      let oAuthToken = await lastOAuthToken;
-      if (
-        oAuthTokenProvider &&
-        (!isValid(oAuthToken) ||
-          isExpired(oAuthToken, oAuthConfiguration?.clockSkew))
-      ) {
-        // Set the shared token for the next API calls to use.
-        lastOAuthToken = oAuthTokenProvider(oAuthToken);
-        oAuthToken = await lastOAuthToken;
-        if (oAuthOnTokenUpdate && oAuthToken) {
-          oAuthOnTokenUpdate(oAuthToken);
-        }
-      }
+      let token = await lastOAuthToken;
+      lastOAuthToken = refreshOAuthToken(
+        token,
+        oAuthTokenProvider,
+        oAuthOnTokenUpdate,
+        oAuthConfiguration?.clockSkew
+      );
+      token = await lastOAuthToken;
       setOAuthTokenInRequest(
-        oAuthToken,
+        token,
         request,
-        oAuthConfiguration?.clockSkew
+        oAuthConfiguration?.clockSkew,
+        setOAuthHeader
       );
       return next(request, options);
     };
   };
 };
 
-function setOAuthTokenInRequest(
-  oAuthToken: OAuthToken | undefined,
-  request: any,
+async function refreshOAuthToken<T extends OAuthTokenConstraints>(
+  currentToken: T | undefined,
+  provider?: (token: T | undefined) => Promise<T>,
+  onUpdate?: (token: T) => void,
   clockSkew?: number
+): Promise<T | undefined> {
+  if (
+    !provider ||
+    (isValid(currentToken) && !isExpired(currentToken, clockSkew))
+  ) {
+    return currentToken;
+  }
+  const newToken = await provider(currentToken);
+  if (newToken && onUpdate) {
+    onUpdate(newToken);
+  }
+  return newToken;
+}
+
+function setOAuthTokenInRequest<T extends OAuthTokenConstraints>(
+  oAuthToken: T | undefined,
+  request: any,
+  clockSkew?: number,
+  setOAuthHeader?: (request: any, token: T) => void
 ) {
   validateAuthorization(oAuthToken, clockSkew);
   request.headers = request.headers ?? {};
+  if (setOAuthHeader && oAuthToken) {
+    setOAuthHeader(request, oAuthToken); // assumes it mutates the request
+    return;
+  }
+
   setHeader(
     request.headers,
     AUTHORIZATION_HEADER,
@@ -60,7 +85,10 @@ function setOAuthTokenInRequest(
   );
 }
 
-function validateAuthorization(oAuthToken?: OAuthToken, clockSkew?: number) {
+function validateAuthorization<T extends OAuthTokenConstraints>(
+  oAuthToken?: T,
+  clockSkew?: number
+) {
   if (!isValid(oAuthToken)) {
     throw new Error(
       'Client is not authorized. An OAuth token is needed to make API calls.'
@@ -74,13 +102,16 @@ function validateAuthorization(oAuthToken?: OAuthToken, clockSkew?: number) {
   }
 }
 
-export function isValid(
-  oAuthToken: OAuthToken | undefined
-): oAuthToken is OAuthToken {
+export function isValid<T extends OAuthTokenConstraints>(
+  oAuthToken: T | undefined
+): oAuthToken is T {
   return typeof oAuthToken !== 'undefined';
 }
 
-export function isExpired(oAuthToken: OAuthToken, clockSkew?: number) {
+export function isExpired<T extends OAuthTokenConstraints>(
+  oAuthToken: T,
+  clockSkew?: number
+) {
   if (typeof oAuthToken.expiry === 'undefined') {
     return false; // Expiry is undefined, token cannot be expired
   }
diff --git a/packages/oauth-adapters/test/oauthAuthenticationAdapter.test.ts b/packages/oauth-adapters/test/oauthAuthenticationAdapter.test.ts
index 10643916e..dd90b20c3 100644
--- a/packages/oauth-adapters/test/oauthAuthenticationAdapter.test.ts
+++ b/packages/oauth-adapters/test/oauthAuthenticationAdapter.test.ts
@@ -1,5 +1,10 @@
-import { callHttpInterceptors } from '../../core/src/http/httpInterceptor';
-import { requestAuthenticationProvider } from '../src/oauthAuthenticationAdapter';
+import { callHttpInterceptors } from '../../core/src';
+import {
+  isExpired,
+  isValid,
+  requestAuthenticationProvider,
+  OAuthConfiguration,
+} from '../src';
 import {
   HttpContext,
   HttpInterceptorInterface,
@@ -7,8 +12,6 @@ import {
   HttpResponse,
   RequestOptions,
 } from '../../core-interfaces/src';
-import { OAuthToken } from '../src/oAuthToken';
-import { isExpired, isValid, OAuthConfiguration } from '../lib';
 
 describe('test oauth request provider', () => {
   it('should pass with disabled authentication', async () => {
@@ -24,7 +27,7 @@ describe('test oauth request provider', () => {
   });
 
   it('should pass with valid token', async () => {
-    const oAuthToken: OAuthToken = {
+    const oAuthToken = {
       accessToken: '1f12495f1a1ad9066b51fb3b4e456aee',
       tokenType: 'Bearer',
       expiresIn: BigInt(100000),
@@ -38,20 +41,20 @@ describe('test oauth request provider', () => {
   });
 
   it('should pass with valid token + authProvider + updateCallback', async () => {
-    const oAuthToken: OAuthToken = {
+    const oAuthToken = {
       accessToken: '1f12495f1a1ad9066b51fb3b4e456aee',
       tokenType: 'Bearer',
       expiresIn: BigInt(100000),
       scope: 'products orders',
       expiry: BigInt(Date.now()),
     };
-    const oAuthTokenProvider = jest.fn((_: OAuthToken | undefined) => {
+    const oAuthTokenProvider = jest.fn((_) => {
       return Promise.resolve({
         accessToken: 'Invalid',
         tokenType: 'Bearer',
       });
     });
-    const oAuthOnTokenUpdate = jest.fn((_: OAuthToken) => {
+    const oAuthOnTokenUpdate = jest.fn((_) => {
       // handler for updated token
     });
     const authenticationProvider = requestAuthenticationProvider(
@@ -76,7 +79,7 @@ describe('test oauth request provider', () => {
   });
 
   it('should pass with undefined token + authProvider + updateCallback', async () => {
-    const oAuthTokenProvider = jest.fn((token: OAuthToken | undefined) => {
+    const oAuthTokenProvider = jest.fn((token) => {
       if (token === undefined) {
         return Promise.resolve({
           accessToken: '1f12495f1a1ad9066b51fb3b4e456aee',
@@ -92,7 +95,7 @@ describe('test oauth request provider', () => {
         accessToken: 'Invalid',
       });
     });
-    const oAuthOnTokenUpdate = jest.fn((_: OAuthToken) => {
+    const oAuthOnTokenUpdate = jest.fn((_) => {
       // handler for updated token
     });
     const authenticationProvider = requestAuthenticationProvider(
@@ -135,7 +138,7 @@ describe('test oauth request provider', () => {
       scope: 'products orders',
       expiry: BigInt(2000),
     };
-    const oAuthTokenProvider = jest.fn((token: OAuthToken | undefined) => {
+    const oAuthTokenProvider = jest.fn((token) => {
       if (token === undefined) {
         // return an invalid token if existing token is undefined
         return Promise.resolve({
@@ -149,7 +152,7 @@ describe('test oauth request provider', () => {
         expiry: BigInt(Date.now()),
       });
     });
-    const oAuthOnTokenUpdate = jest.fn((_: OAuthToken) => {
+    const oAuthOnTokenUpdate = jest.fn((_) => {
       // handler for updated token
     });
 
@@ -174,12 +177,12 @@ describe('test oauth request provider', () => {
 
 describe('isValid', () => {
   it('should return false if oAuthToken is undefined', () => {
-    const token: OAuthToken | undefined = undefined;
+    const token = undefined;
     expect(isValid(token)).toBe(false);
   });
 
   it('should return true if oAuthToken is defined', () => {
-    const token: OAuthToken = {
+    const token = {
       accessToken: '1f12495f1a1ad9066b51fb3b4e456aee',
       tokenType: 'Bearer',
       expiry: BigInt(Math.floor(Date.now() / 1000) + 60),
@@ -188,7 +191,7 @@ describe('isValid', () => {
   });
 
   it('should return true if oAuthToken is defined with no expiry', () => {
-    const token: OAuthToken = {
+    const token = {
       accessToken: '1f12495f1a1ad9066b51fb3b4e456aee',
       tokenType: 'Bearer',
     };
@@ -198,7 +201,7 @@ describe('isValid', () => {
 
 describe('isExpired', () => {
   it('should return false if expiry is undefined', () => {
-    const token: OAuthToken = {
+    const token = {
       accessToken: '1f12495f1a1ad9066b51fb3b4e456aee',
       tokenType: 'Bearer',
       expiry: undefined,
@@ -207,7 +210,7 @@ describe('isExpired', () => {
   });
 
   it('should return false if token is not expired', () => {
-    const token: OAuthToken = {
+    const token = {
       accessToken: '1f12495f1a1ad9066b51fb3b4e456aee',
       tokenType: 'Bearer',
       expiry: BigInt(Math.floor(Date.now() / 1000) + 60),
@@ -216,7 +219,7 @@ describe('isExpired', () => {
   });
 
   it('should return true if token is expired', () => {
-    const token: OAuthToken = {
+    const token = {
       accessToken: '1f12495f1a1ad9066b51fb3b4e456aee',
       tokenType: 'Bearer',
       expiry: BigInt(Math.floor(Date.now() / 1000) - 60),
@@ -225,7 +228,7 @@ describe('isExpired', () => {
   });
 
   it('should correctly apply clock skew and return true if token is close to expiry', () => {
-    const token: OAuthToken = {
+    const token = {
       accessToken: '1f12495f1a1ad9066b51fb3b4e456aee',
       tokenType: 'Bearer',
       expiry: BigInt(Math.floor(Date.now() / 1000) + 30),
@@ -245,7 +248,7 @@ describe('isExpired', () => {
     const oAuthConfiguration: OAuthConfiguration = {
       clockSkew: oneMinOffset * 2,
     };
-    const oAuthTokenProvider = jest.fn((token: OAuthToken | undefined) => {
+    const oAuthTokenProvider = jest.fn((token) => {
       if (token === undefined) {
         // return an invalid token if existing token is undefined
         return Promise.resolve({
@@ -259,7 +262,7 @@ describe('isExpired', () => {
         expiry: BigInt(Date.now()),
       });
     });
-    const oAuthOnTokenUpdate = jest.fn((_: OAuthToken) => {
+    const oAuthOnTokenUpdate = jest.fn((_) => {
       // handler for updated token
     });
 
@@ -283,6 +286,42 @@ describe('isExpired', () => {
   });
 });
 
+describe('oauthAuthenticationAdapter', () => {
+  it('calls custom setOAuthHeader and skips default header', async () => {
+    const token = {
+      accessToken: 'token',
+      expiry: BigInt(Math.floor(Date.now() / 1000) + 1000),
+    };
+    const setOAuthHeader = jest.fn((request, t) => {
+      request.headers['X-Test'] = t.accessToken;
+    });
+
+    const provider = requestAuthenticationProvider(
+      token,
+      undefined,
+      undefined,
+      undefined,
+      setOAuthHeader
+    );
+    const interceptor = provider(true);
+    const req: any = { headers: {} };
+    await interceptor(req, {}, async () => ({
+      request: req,
+      response: { statusCode: 200, body: '', headers: {} },
+    }));
+    expect(setOAuthHeader).toHaveBeenCalled();
+    expect(req.headers['X-Test']).toBe('token');
+    expect(req.headers.Authorization).toBeUndefined();
+  });
+
+  it('subtracts clockSkew from expiry in isExpired', () => {
+    const now = Math.floor(Date.now() / 1000);
+    const token = { accessToken: 't', expiry: BigInt(now + 10) };
+    expect(isExpired(token, 0)).toBe(false);
+    expect(isExpired(token, 20)).toBe(true);
+  });
+});
+
 async function executeAndExpect(
   authenticationProvider: HttpInterceptorInterface<RequestOptions | undefined>,
   headers: Record<string, string> | undefined,


Reviews:
user: login: asadali214
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T13:19:40Z
user: login: asadali214
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T13:21:05Z
user: login: asadali214
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T07:29:52Z
user: login: Ayeshas09
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T07:52:00Z
user: login: asadali214
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-12T12:01:27Z

Review comments:
diff_hunk: @@ -1,19 +1,24 @@
-import { OAuthToken } from './oAuthToken';
 import {
   AuthenticatorInterface,
   passThroughInterceptor,
 } from '@apimatic/core-interfaces';
 import { AUTHORIZATION_HEADER, setHeader } from '@apimatic/http-headers';
 import { OAuthConfiguration } from './oAuthConfiguration';
 
-export const requestAuthenticationProvider = (
-  initialOAuthToken?: OAuthToken,
-  oAuthTokenProvider?: (token: OAuthToken | undefined) => Promise<OAuthToken>,
-  oAuthOnTokenUpdate?: (token: OAuthToken) => void,
-  oAuthConfiguration?: OAuthConfiguration
+export interface OAuthTokenConstraints {
path: packages/oauth-adapters/src/oauthAuthenticationAdapter.ts
user: login: asadali214
body: @Ayeshas09 This interface must not be exported, it's got only an internal use case here^
created_at: 2025-05-09T13:19:40Z
author_association: COLLABORATOR
diff_hunk: @@ -1,19 +1,24 @@
-import { OAuthToken } from './oAuthToken';
 import {
   AuthenticatorInterface,
   passThroughInterceptor,
 } from '@apimatic/core-interfaces';
 import { AUTHORIZATION_HEADER, setHeader } from '@apimatic/http-headers';
 import { OAuthConfiguration } from './oAuthConfiguration';
 
-export const requestAuthenticationProvider = (
-  initialOAuthToken?: OAuthToken,
-  oAuthTokenProvider?: (token: OAuthToken | undefined) => Promise<OAuthToken>,
-  oAuthOnTokenUpdate?: (token: OAuthToken) => void,
-  oAuthConfiguration?: OAuthConfiguration
+export interface OAuthTokenConstraints {
+  accessToken?: string;
+  expiry?: bigint;
+}
+
+export const requestAuthenticationProvider = <T extends OAuthTokenConstraints>(
path: packages/oauth-adapters/src/oauthAuthenticationAdapter.ts
user: login: asadali214
body: @Ayeshas09 Please refactor this function so that it only contains a maximum of 25 lines
created_at: 2025-05-09T13:21:05Z
author_association: COLLABORATOR
diff_hunk: @@ -1,66 +1,94 @@
-import { OAuthToken } from './oAuthToken';
 import {
   AuthenticatorInterface,
   passThroughInterceptor,
 } from '@apimatic/core-interfaces';
 import { AUTHORIZATION_HEADER, setHeader } from '@apimatic/http-headers';
 import { OAuthConfiguration } from './oAuthConfiguration';
 
-export const requestAuthenticationProvider = (
-  initialOAuthToken?: OAuthToken,
-  oAuthTokenProvider?: (token: OAuthToken | undefined) => Promise<OAuthToken>,
-  oAuthOnTokenUpdate?: (token: OAuthToken) => void,
-  oAuthConfiguration?: OAuthConfiguration
+interface OAuthTokenConstraints {
+  accessToken?: string;
+  expiry?: bigint;
+}
+
+async function refreshOAuthToken<T extends OAuthTokenConstraints>(
+  currentToken: T | undefined,
+  provider: (token: T | undefined) => Promise<T>,
+  onUpdate?: (token: T) => void,
+  clockSkew?: number
+): Promise<T | undefined> {
+  if (
+    !provider ||
+    (isValid(currentToken) && !isExpired(currentToken as T, clockSkew))
path: packages/oauth-adapters/src/oauthAuthenticationAdapter.ts
user: login: asadali214
body: The casting to the generic type T is unnecessary here and can be safely removed. Additionally, please adjust the file structure to follow best practices by moving the exported function to the top of the file for better readability and maintainability.
created_at: 2025-05-12T07:29:51Z
author_association: COLLABORATOR
diff_hunk: @@ -1,19 +1,24 @@
-import { OAuthToken } from './oAuthToken';
 import {
   AuthenticatorInterface,
   passThroughInterceptor,
 } from '@apimatic/core-interfaces';
 import { AUTHORIZATION_HEADER, setHeader } from '@apimatic/http-headers';
 import { OAuthConfiguration } from './oAuthConfiguration';
 
-export const requestAuthenticationProvider = (
-  initialOAuthToken?: OAuthToken,
-  oAuthTokenProvider?: (token: OAuthToken | undefined) => Promise<OAuthToken>,
-  oAuthOnTokenUpdate?: (token: OAuthToken) => void,
-  oAuthConfiguration?: OAuthConfiguration
+export interface OAuthTokenConstraints {
path: packages/oauth-adapters/src/oauthAuthenticationAdapter.ts
user: login: Ayeshas09
body: This has been done
created_at: 2025-05-12T07:52:00Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-12T11:53:58Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=apimatic_apimatic-js-runtime&pullRequest=232) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=apimatic_apimatic-js-runtime&pullRequest=232&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=apimatic_apimatic-js-runtime&pullRequest=232&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=apimatic_apimatic-js-runtime&pullRequest=232&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=apimatic_apimatic-js-runtime&pullRequest=232&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.8% Duplication on New Code](https://sonarcloud.io/component_measures?id=apimatic_apimatic-js-runtime&pullRequest=232&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=apimatic_apimatic-js-runtime&pullRequest=232)


author_association: NONE