@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 +22 -3
- package/lib/client-credentials.d.ts +29 -2
- package/lib/client-credentials.js +118 -31
- package/lib/generated-types/management.d.ts +721 -47
- package/lib/management.d.ts +44 -4
- package/lib/management.js +60 -9
- package/lib/timeout.d.ts +7 -0
- package/lib/timeout.js +29 -0
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
25
|
-
if (
|
|
26
|
-
this.accessToken.expiresAt
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
throw new ClientCredentialsError('Invalid response from token endpoint');
|
|
98
|
+
finally {
|
|
99
|
+
abort.clear();
|
|
45
100
|
}
|
|
46
|
-
|
|
47
|
-
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error instanceof ClientCredentialsError) {
|
|
104
|
+
throw error;
|
|
48
105
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
}
|