Skip to content
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
130 changes: 129 additions & 1 deletion packages/javascript/src/ThunderIDJavaScriptClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import OIDCDiscoveryConstants from './constants/OIDCDiscoveryConstants';
import OIDCRequestConstants from './constants/OIDCRequestConstants';
import PKCEConstants from './constants/PKCEConstants';
import TokenConstants from './constants/TokenConstants';
import VendorConstants from './constants/VendorConstants';
import {DefaultCacheStore} from './DefaultCacheStore';
import {DefaultCrypto} from './DefaultCrypto';
Expand All @@ -36,7 +37,7 @@ import {OIDCDiscoveryApiResponse} from './models/oidc-discovery';
import {OIDCEndpoints} from './models/oidc-endpoints';
import {SessionData, UserSession} from './models/session';
import {Storage, TemporaryStore} from './models/store';
import {IdToken, TokenExchangeRequestConfig, TokenResponse} from './models/token';
import {AccessTokenApiResponse, IdToken, TokenExchangeRequestConfig, TokenResponse} from './models/token';
import {User, UserProfile} from './models/user';
import StorageManager from './StorageManager';
import AuthenticationHelper from './utils/AuthenticationHelper';
Expand Down Expand Up @@ -183,9 +184,35 @@ class ThunderIDJavaScriptClient<T = Config> implements ThunderIDClient<T> {
}

public async getAccessToken(sessionId?: string): Promise<string> {
const configData = await this.configProvider();

if (configData.grantType === 'client_credentials') {
const sessionData = await this.storageManager.getSessionData(sessionId);

if (sessionData?.access_token && this.isCachedTokenStillValid(sessionData)) {
return sessionData.access_token;
}

const tokenResponse = await this.requestClientCredentialsToken(sessionId);

return tokenResponse.accessToken;
}

return (await this.storageManager.getSessionData(sessionId))?.access_token;
}

private isCachedTokenStillValid(sessionData: SessionData): boolean {
if (!sessionData.created_at || !sessionData.expires_in) {
return false;
}

const expiresInMs = parseInt(sessionData.expires_in, 10) * 1000;

return (
sessionData.created_at + expiresInMs - TokenConstants.Lifecycle.CLIENT_CREDENTIALS_REFRESH_MARGIN_MS > Date.now()
);
}
Comment on lines 186 to +214

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add request coalescing for concurrent client_credentials token fetches.

When the cached token is missing or stale, every concurrent caller of getAccessToken() independently calls requestClientCredentialsToken. Under concurrent load (the typical case for a machine-to-machine client backing a busy service), this sends multiple simultaneous requests to the token endpoint for the same client. This can trigger rate limiting on the authorization server or add avoidable load during a stampede.

Cache the in-flight request promise so concurrent callers await the same request instead of issuing separate ones.

🔧 Proposed fix to coalesce concurrent client-credentials requests
+  private clientCredentialsTokenPromise?: Promise<TokenResponse>;
+
   public async getAccessToken(sessionId?: string): Promise<string> {
     const configData = await this.configProvider();

     if (configData.grantType === 'client_credentials') {
       const sessionData = await this.storageManager.getSessionData(sessionId);

       if (sessionData?.access_token && this.isCachedTokenStillValid(sessionData)) {
         return sessionData.access_token;
       }

-      const tokenResponse = await this.requestClientCredentialsToken(sessionId);
+      if (!this.clientCredentialsTokenPromise) {
+        this.clientCredentialsTokenPromise = this.requestClientCredentialsToken(sessionId).finally(() => {
+          this.clientCredentialsTokenPromise = undefined;
+        });
+      }
+
+      const tokenResponse = await this.clientCredentialsTokenPromise;

       return tokenResponse.accessToken;
     }

     return (await this.storageManager.getSessionData(sessionId))?.access_token;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public async getAccessToken(sessionId?: string): Promise<string> {
const configData = await this.configProvider();
if (configData.grantType === 'client_credentials') {
const sessionData = await this.storageManager.getSessionData(sessionId);
if (sessionData?.access_token && this.isCachedTokenStillValid(sessionData)) {
return sessionData.access_token;
}
const tokenResponse = await this.requestClientCredentialsToken(sessionId);
return tokenResponse.accessToken;
}
return (await this.storageManager.getSessionData(sessionId))?.access_token;
}
private isCachedTokenStillValid(sessionData: SessionData): boolean {
if (!sessionData.created_at || !sessionData.expires_in) {
return false;
}
const expiresInMs = parseInt(sessionData.expires_in, 10) * 1000;
return (
sessionData.created_at + expiresInMs - TokenConstants.Lifecycle.CLIENT_CREDENTIALS_REFRESH_MARGIN_MS > Date.now()
);
}
private clientCredentialsTokenPromise?: Promise<TokenResponse>;
public async getAccessToken(sessionId?: string): Promise<string> {
const configData = await this.configProvider();
if (configData.grantType === 'client_credentials') {
const sessionData = await this.storageManager.getSessionData(sessionId);
if (sessionData?.access_token && this.isCachedTokenStillValid(sessionData)) {
return sessionData.access_token;
}
if (!this.clientCredentialsTokenPromise) {
this.clientCredentialsTokenPromise = this.requestClientCredentialsToken(sessionId).finally(() => {
this.clientCredentialsTokenPromise = undefined;
});
}
const tokenResponse = await this.clientCredentialsTokenPromise;
return tokenResponse.accessToken;
}
return (await this.storageManager.getSessionData(sessionId))?.access_token;
}
private isCachedTokenStillValid(sessionData: SessionData): boolean {
if (!sessionData.created_at || !sessionData.expires_in) {
return false;
}
const expiresInMs = parseInt(sessionData.expires_in, 10) * 1000;
return (
sessionData.created_at + expiresInMs - TokenConstants.Lifecycle.CLIENT_CREDENTIALS_REFRESH_MARGIN_MS > Date.now()
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/javascript/src/ThunderIDJavaScriptClient.ts` around lines 186 - 214,
Add an in-flight token-request promise around getAccessToken’s
client_credentials path so concurrent callers share one
requestClientCredentialsToken invocation when the cached token is missing or
stale. Reuse the shared promise’s result for all callers and clear it after
completion, including rejection, so later calls can retry; preserve existing
cached-token and non-client_credentials behavior.


public clearSession(sessionId?: string): void {
this.authHelper.clearSession(sessionId);
}
Expand Down Expand Up @@ -767,6 +794,107 @@ class ThunderIDJavaScriptClient<T = Config> implements ThunderIDClient<T> {
return this.authHelper.handleTokenResponse(tokenResponse, userId);
}

/**
* Requests a machine-to-machine access token via the `client_credentials` grant
* (RFC 6749 §4.4) and caches it under the given session key. Unlike the user-facing
* flows, there's no id_token or refresh_token: the service authenticates as itself.
*/
protected async requestClientCredentialsToken(sessionId?: string): Promise<TokenResponse> {
if (
!(await this.storageManager.getTemporaryDataParameter(
OIDCDiscoveryConstants.Storage.StorageKeys.OPENID_PROVIDER_CONFIG_INITIATED,
))
) {
await this.loadOpenIDProviderConfiguration(false);
}

const tokenEndpoint: string | undefined = (await this.oidcProviderMetaDataProvider()).token_endpoint;
const configData = await this.configProvider();

if (!tokenEndpoint || tokenEndpoint.trim().length === 0) {
throw new ThunderIDAuthException(
'JS-AUTH_CORE-RCCT-NF01',
'Token endpoint not found.',
'No token endpoint was found in the OIDC provider meta data returned by the well-known endpoint ' +
'or the token endpoint passed to the SDK is empty.',
);
}

const body: URLSearchParams = new URLSearchParams();

body.set('grant_type', 'client_credentials');
body.set('client_id', configData.clientId ?? '');

const hasSecret = Boolean(configData.clientSecret && configData.clientSecret.trim().length > 0);
const tokenEndpointAuthMethod = configData.tokenRequest?.authMethod ?? 'client_secret_basic';

if (hasSecret && tokenEndpointAuthMethod === 'client_secret_post') {
body.set('client_secret', configData.clientSecret!);
}

const scope = Array.isArray(configData.scopes) ? configData.scopes.join(' ') : configData.scopes;

if (scope) {
body.set('scope', scope);
}

if (configData.tokenRequest?.params) {
Object.entries(configData.tokenRequest.params).forEach(([key, value]: [string, unknown]) => {
body.set(key, value as string);
});
}

const tokenRequestHeaders: Record<string, string> = {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
};

if (hasSecret && tokenEndpointAuthMethod === 'client_secret_basic') {
const credential = `${encodeURIComponent(configData.clientId!)}:${encodeURIComponent(configData.clientSecret!)}`;
tokenRequestHeaders['Authorization'] = `Basic ${base64Encode(credential)}`;
}

let tokenResponse: Response;

try {
tokenResponse = await fetch(tokenEndpoint, {
body,
headers: tokenRequestHeaders,
method: 'POST',
});
} catch (error: any) {
throw new ThunderIDAuthException(
'JS-AUTH_CORE-RCCT-NE02',
'Requesting client credentials token failed.',
error ?? 'The request to get the client credentials access token failed.',
);
}

if (!tokenResponse.ok) {
throw new ThunderIDAuthException(
'JS-AUTH_CORE-RCCT-HE03',
`Requesting client credentials token failed with ${tokenResponse.statusText}`,
(await tokenResponse.json()) as string,
);
}

const parsedResponse = (await tokenResponse.json()) as AccessTokenApiResponse;

parsedResponse.created_at = new Date().getTime();

await this.storageManager.setSessionData(parsedResponse, sessionId);

return {
accessToken: parsedResponse.access_token,
createdAt: parsedResponse.created_at,
expiresIn: parsedResponse.expires_in,
idToken: parsedResponse.id_token ?? '',
refreshToken: parsedResponse.refresh_token ?? '',
scope: parsedResponse.scope,
tokenType: parsedResponse.token_type,
};
}

protected async revokeAccessToken(userId?: string): Promise<Response | boolean> {
const revokeTokenEndpoint: string | undefined = (await this.oidcProviderMetaDataProvider()).revocation_endpoint;
const configData = await this.configProvider();
Expand Down
15 changes: 15 additions & 0 deletions packages/javascript/src/constants/TokenConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ const TokenConstants: {
readonly REFRESH_TOKEN_TIMER: string;
};
};
readonly Lifecycle: {
readonly CLIENT_CREDENTIALS_REFRESH_MARGIN_MS: number;
};
} = {
/**
* Token signature validation constants.
Expand Down Expand Up @@ -84,6 +87,18 @@ const TokenConstants: {
REFRESH_TOKEN_TIMER: 'refresh_token_timer',
},
},

/**
* Token lifecycle timing constants.
* Contains timing thresholds used to decide when cached tokens should be refreshed.
*/
Lifecycle: {
/**
* How far ahead of expiry a cached `client_credentials` token is treated as
* stale and refreshed.
*/
CLIENT_CREDENTIALS_REFRESH_MARGIN_MS: 30_000,
},
} as const;

export default TokenConstants;
13 changes: 13 additions & 0 deletions packages/javascript/src/models/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,19 @@ export interface BaseConfig<T = unknown> extends WithPreferences, WithExtensions
*/
mode?: 'redirect' | 'embedded';

/**
* The OAuth 2.0 grant type this client uses to obtain access tokens.
*
* - `'authorization_code'` (default) — standard user sign-in flow. `getAccessToken()`
* only ever reads from the stored session; it never fetches a token on its own.
* - `'client_credentials'` — machine-to-machine flow (RFC 6749 §4.4). There's no user
* and no sign-in step: `getAccessToken()` fetches (and transparently caches/refreshes)
* a token for the service itself, using `clientId`/`clientSecret`.
*
* @default 'authorization_code'
*/
grantType?: 'authorization_code' | 'client_credentials';

/**
* Configuration for chaining authentication across multiple organization contexts.
* Used when you need to authenticate a user in one organization using credentials
Expand Down
Loading
Loading