@logto/api 1.42.0 → 1.44.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,8 +33,9 @@ const { apiClient } = createManagementApi('your-tenant-id', {
33
33
  clientSecret: 'your-client-secret',
34
34
  });
35
35
 
36
- // Make API calls
37
- const response = await apiClient.GET('/api/users');
36
+ // Make API calls with lowercase or uppercase methods
37
+ const response = await apiClient.get('/api/users');
38
+ // const response = await apiClient.GET('/api/users');
38
39
  console.log(response.data);
39
40
  ```
40
41
 
@@ -51,6 +52,24 @@ const { apiClient } = createManagementApi('default', {
51
52
  });
52
53
  ```
53
54
 
55
+ #### Timeouts
56
+
57
+ Token fetches and Management API network requests have separate 10-second timeouts. The API request
58
+ timeout starts after token retrieval, so a request that needs a new token can use both timeout
59
+ periods. Use `tokenRequestTimeout` and `requestTimeout` to configure them in seconds. Set either option
60
+ to `0` or a negative value to disable its timeout.
61
+
62
+ Use `getTokenRequestSignal` to return a custom signal for each token request. The signal is composed
63
+ with `tokenRequestTimeout`, and the first one to abort cancels the token request. A per-request
64
+ `signal` is similarly composed with `requestTimeout` for the Management API request. Timeouts and
65
+ custom cancellation reject the API call instead of returning a response object.
66
+
67
+ #### Token refresh
68
+
69
+ After a Management API request returns `401`, the next request fetches a new token. If the replacement
70
+ token also receives a `401`, it remains cached until a request succeeds or the token expires. The SDK
71
+ does not automatically retry the failed API request.
72
+
54
73
  #### Custom authentication
55
74
 
56
75
  For advanced use cases where you need full control over the authentication logic, use `createApiClient`:
@@ -67,7 +86,7 @@ const client = createApiClient({
67
86
  });
68
87
 
69
88
  // Type-safe API calls
70
- const response = await client.GET('/api/applications/{id}', {
89
+ const response = await client.get('/api/applications/{id}', {
71
90
  params: { path: { id: 'your-app-id' } },
72
91
  });
73
92
  ```
@@ -6,7 +6,7 @@ export type AccessToken = {
6
6
  scope?: string;
7
7
  };
8
8
  export declare class ClientCredentialsError extends Error {
9
- constructor(message: string);
9
+ constructor(message: string, options?: ErrorOptions);
10
10
  }
11
11
  export type ClientCredentialsOptions = {
12
12
  clientId: string;
@@ -15,9 +15,23 @@ export type ClientCredentialsOptions = {
15
15
  tokenParams?: Record<string, string>;
16
16
  /**
17
17
  * The time in seconds before the access token expires to consider it valid.
18
+ * A value greater than or equal to the token lifetime causes every call to fetch a new token,
19
+ * so cached-token invalidation protection cannot persist across calls.
18
20
  * @default 60
19
21
  */
20
22
  accessTokenExpiryLeeway?: Seconds;
23
+ /**
24
+ * The maximum time in seconds allowed for fetching an access token.
25
+ * Non-positive and infinite values disable the timeout. `NaN` uses the default value.
26
+ * @default 10
27
+ */
28
+ tokenRequestTimeout?: Seconds;
29
+ /**
30
+ * Returns an optional signal for each token request. The factory is called once per actual token
31
+ * fetch, so concurrent callers sharing a fetch also share its signal. The signal is composed with
32
+ * `tokenRequestTimeout`, and the first signal to abort cancels the request.
33
+ */
34
+ getTokenRequestSignal?: () => AbortSignal | undefined;
21
35
  };
22
36
  /**
23
37
  * A class handles client credentials for API authentication. It caches the access token
@@ -26,12 +40,25 @@ export type ClientCredentialsOptions = {
26
40
  export declare class ClientCredentials {
27
41
  protected options: ClientCredentialsOptions;
28
42
  protected accessToken?: AccessToken;
43
+ private accessTokenPromise?;
44
+ private awaitingValidationAfterRefresh;
29
45
  get accessTokenExpiryLeeway(): Seconds;
46
+ get tokenRequestTimeout(): Seconds;
30
47
  constructor(options: ClientCredentialsOptions);
31
48
  /**
32
49
  * Retrieves the access token, refreshing it if necessary.
33
- * @returns The access token as a string.
50
+ * Concurrent calls share the same token request.
51
+ * @returns The access token and its metadata.
34
52
  */
35
53
  getAccessToken(): Promise<AccessToken>;
54
+ /**
55
+ * Invalidates the cached token if it matches the token rejected by the API.
56
+ * A delayed response for an older token must not invalidate a newer cached token.
57
+ */
58
+ invalidateAccessToken(token: string): void;
59
+ /** Allows a future 401 to invalidate the token after it receives a successful API response. */
60
+ markAccessTokenAsValid(token: string): void;
61
+ private fetchAccessToken;
62
+ private requestAccessToken;
36
63
  }
37
64
  export {};
@@ -1,9 +1,29 @@
1
+ import { createTimeoutSignal, getAbortReason, normalizeTimeout } from './timeout.js';
1
2
  export class ClientCredentialsError extends Error {
2
- constructor(message) {
3
- super(message);
3
+ constructor(message, options) {
4
+ super(message, options);
4
5
  this.name = 'ClientCredentialsError';
5
6
  }
6
7
  }
8
+ const createAbortPromise = (signal) => {
9
+ // eslint-disable-next-line no-use-extend-native/no-use-extend-native -- `Promise.withResolvers` is standard since ES2024; the rule's built-in method list predates it.
10
+ const { promise, reject } = Promise.withResolvers();
11
+ const handleAbort = () => {
12
+ reject(getAbortReason(signal, 'Token request aborted'));
13
+ };
14
+ if (signal.aborted) {
15
+ handleAbort();
16
+ }
17
+ else {
18
+ signal.addEventListener('abort', handleAbort, { once: true });
19
+ }
20
+ return {
21
+ promise,
22
+ clear: () => {
23
+ signal.removeEventListener('abort', handleAbort);
24
+ },
25
+ };
26
+ };
7
27
  /**
8
28
  * A class handles client credentials for API authentication. It caches the access token
9
29
  * and provides methods to retrieve it, ensuring the token is valid and refreshing it when necessary.
@@ -12,46 +32,113 @@ export class ClientCredentials {
12
32
  get accessTokenExpiryLeeway() {
13
33
  return this.options.accessTokenExpiryLeeway ?? 60;
14
34
  }
35
+ get tokenRequestTimeout() {
36
+ return normalizeTimeout(this.options.tokenRequestTimeout);
37
+ }
15
38
  constructor(options) {
16
39
  this.options = options;
40
+ this.awaitingValidationAfterRefresh = false;
17
41
  }
18
42
  /**
19
43
  * Retrieves the access token, refreshing it if necessary.
20
- * @returns The access token as a string.
44
+ * Concurrent calls share the same token request.
45
+ * @returns The access token and its metadata.
21
46
  */
22
47
  async getAccessToken() {
23
48
  const now = new Date();
24
- // If the access token is not set or has expired, refresh it
25
- if (!this.accessToken?.expiresAt ||
26
- this.accessToken.expiresAt <= Math.floor(now.getTime() / 1000) + this.accessTokenExpiryLeeway) {
27
- const response = await fetch(this.options.tokenEndpoint, {
28
- method: 'POST',
29
- headers: {
30
- 'Content-Type': 'application/x-www-form-urlencoded',
31
- },
32
- body: new URLSearchParams({
33
- grant_type: 'client_credentials',
34
- client_id: this.options.clientId,
35
- client_secret: this.options.clientSecret,
36
- ...this.options.tokenParams,
37
- }).toString(),
38
- });
39
- if (!response.ok) {
40
- throw new ClientCredentialsError(`Failed to fetch access token: ${response.status} ${response.statusText}`);
49
+ // Return the cached token if it is still valid.
50
+ if (this.accessToken?.expiresAt &&
51
+ this.accessToken.expiresAt > Math.floor(now.getTime() / 1000) + this.accessTokenExpiryLeeway) {
52
+ return this.accessToken;
53
+ }
54
+ if (this.accessToken) {
55
+ // A natural expiry starts a new invalidation cycle.
56
+ this.awaitingValidationAfterRefresh = false;
57
+ }
58
+ if (this.accessTokenPromise) {
59
+ return this.accessTokenPromise;
60
+ }
61
+ try {
62
+ this.accessTokenPromise = this.fetchAccessToken();
63
+ this.accessToken = await this.accessTokenPromise;
64
+ return this.accessToken;
65
+ }
66
+ finally {
67
+ this.accessTokenPromise = undefined;
68
+ }
69
+ }
70
+ /**
71
+ * Invalidates the cached token if it matches the token rejected by the API.
72
+ * A delayed response for an older token must not invalidate a newer cached token.
73
+ */
74
+ invalidateAccessToken(token) {
75
+ if (this.accessToken?.value === token && !this.awaitingValidationAfterRefresh) {
76
+ this.accessToken = undefined;
77
+ this.awaitingValidationAfterRefresh = true;
78
+ }
79
+ }
80
+ /** Allows a future 401 to invalidate the token after it receives a successful API response. */
81
+ markAccessTokenAsValid(token) {
82
+ if (this.accessToken?.value === token) {
83
+ this.awaitingValidationAfterRefresh = false;
84
+ }
85
+ }
86
+ async fetchAccessToken() {
87
+ const timeout = createTimeoutSignal(this.tokenRequestTimeout, 'Token request timed out');
88
+ try {
89
+ const customSignal = this.options.getTokenRequestSignal?.();
90
+ const signal = customSignal
91
+ ? AbortSignal.any([timeout.signal, customSignal])
92
+ : timeout.signal;
93
+ const abort = createAbortPromise(signal);
94
+ try {
95
+ // Guard against Response implementations that do not propagate request aborts to json().
96
+ return await Promise.race([this.requestAccessToken(signal), abort.promise]);
41
97
  }
42
- const data = await response.json();
43
- if (typeof data !== 'object' || data === null || !('access_token' in data)) {
44
- throw new ClientCredentialsError('Invalid response from token endpoint');
98
+ finally {
99
+ abort.clear();
45
100
  }
46
- if (!('expires_in' in data) || typeof data.expires_in !== 'number') {
47
- throw new ClientCredentialsError('Invalid or missing expires_in in token response');
101
+ }
102
+ catch (error) {
103
+ if (error instanceof ClientCredentialsError) {
104
+ throw error;
48
105
  }
49
- this.accessToken = {
50
- value: String(data.access_token),
51
- expiresAt: Math.floor(now.getTime() / 1000) + data.expires_in,
52
- scope: 'scope' in data && typeof data.scope === 'string' ? data.scope : undefined,
53
- };
106
+ throw new ClientCredentialsError(`Failed to fetch access token: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
107
+ }
108
+ finally {
109
+ timeout.clear();
110
+ }
111
+ }
112
+ async requestAccessToken(signal) {
113
+ const now = new Date();
114
+ const response = await fetch(this.options.tokenEndpoint, {
115
+ method: 'POST',
116
+ redirect: 'error',
117
+ signal,
118
+ headers: {
119
+ 'Content-Type': 'application/x-www-form-urlencoded',
120
+ },
121
+ body: new URLSearchParams({
122
+ grant_type: 'client_credentials',
123
+ client_id: this.options.clientId,
124
+ client_secret: this.options.clientSecret,
125
+ ...this.options.tokenParams,
126
+ }).toString(),
127
+ });
128
+ if (!response.ok) {
129
+ throw new ClientCredentialsError(`Failed to fetch access token: ${response.status} ${response.statusText}`);
130
+ }
131
+ const data = await response.json();
132
+ if (typeof data !== 'object' || data === null || !('access_token' in data)) {
133
+ throw new ClientCredentialsError('Invalid response from token endpoint');
134
+ }
135
+ if (!('expires_in' in data) || typeof data.expires_in !== 'number') {
136
+ throw new ClientCredentialsError('Invalid or missing expires_in in token response');
54
137
  }
55
- return this.accessToken;
138
+ return {
139
+ value: String(data.access_token),
140
+ expiresAt: Math.floor(now.getTime() / 1000) + data.expires_in,
141
+ scope: 'scope' in data && typeof data.scope === 'string' ? data.scope : undefined,
142
+ };
56
143
  }
57
144
  }