@logto/client 2.2.4 → 2.3.1

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/lib/errors.cjs CHANGED
@@ -1,11 +1,14 @@
1
1
  'use strict';
2
2
 
3
+ var js = require('@logto/js');
4
+
3
5
  const logtoClientErrorCodes = Object.freeze({
4
6
  'sign_in_session.invalid': 'Invalid sign-in session.',
5
7
  'sign_in_session.not_found': 'Sign-in session not found.',
6
8
  not_authenticated: 'Not authenticated.',
7
9
  fetch_user_info_failed: 'Unable to fetch user info. The access token may be invalid.',
8
10
  user_cancelled: 'The user cancelled the action.',
11
+ missing_scope_organizations: `The \`${js.UserScope.Organizations}\` scope is required`,
9
12
  });
10
13
  class LogtoClientError extends Error {
11
14
  constructor(code, data) {
package/lib/errors.d.ts CHANGED
@@ -4,6 +4,7 @@ declare const logtoClientErrorCodes: Readonly<{
4
4
  not_authenticated: "Not authenticated.";
5
5
  fetch_user_info_failed: "Unable to fetch user info. The access token may be invalid.";
6
6
  user_cancelled: "The user cancelled the action.";
7
+ missing_scope_organizations: "The `urn:logto:scope:organizations` scope is required";
7
8
  }>;
8
9
  export type LogtoClientErrorCode = keyof typeof logtoClientErrorCodes;
9
10
  export declare class LogtoClientError extends Error {
package/lib/errors.js CHANGED
@@ -1,9 +1,12 @@
1
+ import { UserScope } from '@logto/js';
2
+
1
3
  const logtoClientErrorCodes = Object.freeze({
2
4
  'sign_in_session.invalid': 'Invalid sign-in session.',
3
5
  'sign_in_session.not_found': 'Sign-in session not found.',
4
6
  not_authenticated: 'Not authenticated.',
5
7
  fetch_user_info_failed: 'Unable to fetch user info. The access token may be invalid.',
6
8
  user_cancelled: 'The user cancelled the action.',
9
+ missing_scope_organizations: `The \`${UserScope.Organizations}\` scope is required`,
7
10
  });
8
11
  class LogtoClientError extends Error {
9
12
  constructor(code, data) {
package/lib/index.cjs CHANGED
@@ -14,6 +14,7 @@ var once = require('./utils/once.cjs');
14
14
  var types = require('./adapter/types.cjs');
15
15
  var requester = require('./utils/requester.cjs');
16
16
 
17
+ /* eslint-disable max-lines */
17
18
  /**
18
19
  * The Logto base client class that provides the essential methods for
19
20
  * interacting with the Logto server.
@@ -23,9 +24,13 @@ var requester = require('./utils/requester.cjs');
23
24
  */
24
25
  class LogtoClient {
25
26
  constructor(logtoConfig, adapter) {
26
- this.getOidcConfig = memoize.memoize(this.#getOidcConfig);
27
27
  /**
28
- * Get the access token from the storage.
28
+ * Get the OIDC configuration from the discovery endpoint. This method will
29
+ * only fetch the configuration once and cache the result.
30
+ */
31
+ this.getOidcConfig = once.once(this.#getOidcConfig);
32
+ /**
33
+ * Get the access token from the storage with refresh strategy.
29
34
  *
30
35
  * - If the access token has expired, it will try to fetch a new one using the Refresh Token.
31
36
  * - If there's an ongoing Promise to fetch the access token, it will return the Promise.
@@ -39,13 +44,32 @@ class LogtoClient {
39
44
  * @throws LogtoClientError if the user is not authenticated.
40
45
  */
41
46
  this.getAccessToken = memoize.memoize(this.#getAccessToken);
47
+ /**
48
+ * Get the access token for the specified organization from the storage with refresh strategy.
49
+ *
50
+ * Scope {@link UserScope.Organizations} is required in the config to use organization-related
51
+ * methods.
52
+ *
53
+ * @param organizationId The ID of the organization that the access token is granted for.
54
+ * @returns The access token string.
55
+ * @throws LogtoClientError if the user is not authenticated.
56
+ * @remarks
57
+ * It uses the same refresh strategy as {@link getAccessToken}.
58
+ */
59
+ this.getOrganizationToken = memoize.memoize(this.#getOrganizationToken);
60
+ /**
61
+ * Handle the sign-in callback by parsing the authorization code from the
62
+ * callback URI and exchanging it for the tokens.
63
+ *
64
+ * @param callbackUri The callback URI, including the search params, that the user is redirected to after the sign-in flow is completed.
65
+ * The origin and pathname of this URI must match the origin and pathname of the redirect URI specified in {@link signIn}.
66
+ * In many cases you'll probably end up passing `window.location.href` as the argument to this function.
67
+ * @throws LogtoClientError if the sign-in session is not found.
68
+ */
69
+ this.handleSignInCallback = memoize.memoize(this.#handleSignInCallback);
42
70
  this.getJwtVerifyGetKey = once.once(this.#getJwtVerifyGetKey);
43
71
  this.accessTokenMap = new Map();
44
- this.logtoConfig = {
45
- ...logtoConfig,
46
- prompt: logtoConfig.prompt ?? js.Prompt.Consent,
47
- scopes: js.withDefaultScopes(logtoConfig.scopes).split(' '),
48
- };
72
+ this.logtoConfig = index.normalizeLogtoConfig(logtoConfig);
49
73
  this.adapter = new index$1.ClientAdapterInstance(adapter);
50
74
  void this.loadAccessTokenMap();
51
75
  }
@@ -89,6 +113,15 @@ class LogtoClient {
89
113
  const accessToken = await this.getAccessToken(resource);
90
114
  return js.decodeAccessToken(accessToken);
91
115
  }
116
+ /**
117
+ * Get the organization token claims for the specified organization.
118
+ *
119
+ * @param organizationId The ID of the organization that the access token is granted for.
120
+ */
121
+ async getOrganizationTokenClaims(organizationId) {
122
+ const accessToken = await this.getOrganizationToken(organizationId);
123
+ return js.decodeAccessToken(accessToken);
124
+ }
92
125
  /**
93
126
  * Get the user information from the Userinfo Endpoint.
94
127
  *
@@ -164,50 +197,6 @@ class LogtoClient {
164
197
  const { origin, pathname } = new URL(url);
165
198
  return `${origin}${pathname}` === redirectUri;
166
199
  }
167
- /**
168
- * Handle the sign-in callback by parsing the authorization code from the
169
- * callback URI and exchanging it for the tokens.
170
- *
171
- * @param callbackUri The callback URI, including the search params, that the user is redirected to after the sign-in flow is completed.
172
- * The origin and pathname of this URI must match the origin and pathname of the redirect URI specified in {@link signIn}.
173
- * In many cases you'll probably end up passing `window.location.href` as the argument to this function.
174
- * @throws LogtoClientError if the sign-in session is not found.
175
- */
176
- async handleSignInCallback(callbackUri) {
177
- const { requester } = this.adapter;
178
- const signInSession = await this.getSignInSession();
179
- if (!signInSession) {
180
- throw new errors.LogtoClientError('sign_in_session.not_found');
181
- }
182
- const { redirectUri, state, codeVerifier } = signInSession;
183
- const code = js.verifyAndParseCodeFromCallbackUri(callbackUri, redirectUri, state);
184
- // NOTE: Will add scope to accessTokenKey when needed. (Linear issue LOG-1589)
185
- const accessTokenKey = index$2.buildAccessTokenKey();
186
- const { appId: clientId } = this.logtoConfig;
187
- const { tokenEndpoint } = await this.getOidcConfig();
188
- const requestedAt = Math.round(Date.now() / 1000);
189
- const { idToken, refreshToken, accessToken, scope, expiresIn } = await js.fetchTokenByAuthorizationCode({
190
- clientId,
191
- tokenEndpoint,
192
- redirectUri,
193
- codeVerifier,
194
- code,
195
- }, requester);
196
- await this.verifyIdToken(idToken);
197
- await this.setRefreshToken(refreshToken ?? null);
198
- await this.setIdToken(idToken);
199
- this.accessTokenMap.set(accessTokenKey, {
200
- token: accessToken,
201
- scope,
202
- /** The `expiresAt` variable provides an approximate estimation of the actual `exp` property
203
- * in the token claims. It is utilized by the client to determine if the cached access token
204
- * has expired and when a new access token should be requested.
205
- */
206
- expiresAt: requestedAt + expiresIn,
207
- });
208
- await this.saveAccessTokenMap();
209
- await this.setSignInSession(null);
210
- }
211
200
  /**
212
201
  * Start the sign-out flow with the specified redirect URI. The URI must be
213
202
  * registered in the Logto Console.
@@ -263,12 +252,12 @@ class LogtoClient {
263
252
  async setRefreshToken(value) {
264
253
  return this.adapter.setStorageItem(types.PersistKey.RefreshToken, value);
265
254
  }
266
- async getAccessTokenByRefreshToken(resource) {
255
+ async getAccessTokenByRefreshToken(resource, organizationId) {
267
256
  const currentRefreshToken = await this.getRefreshToken();
268
257
  if (!currentRefreshToken) {
269
258
  throw new errors.LogtoClientError('not_authenticated');
270
259
  }
271
- const accessTokenKey = index$2.buildAccessTokenKey(resource);
260
+ const accessTokenKey = index$2.buildAccessTokenKey(resource, organizationId);
272
261
  const { appId: clientId } = this.logtoConfig;
273
262
  const { tokenEndpoint } = await this.getOidcConfig();
274
263
  const requestedAt = Math.round(Date.now() / 1000);
@@ -277,6 +266,7 @@ class LogtoClient {
277
266
  tokenEndpoint,
278
267
  refreshToken: currentRefreshToken,
279
268
  resource,
269
+ organizationId,
280
270
  }, this.adapter.requester);
281
271
  this.accessTokenMap.set(accessTokenKey, {
282
272
  token: accessToken,
@@ -343,11 +333,11 @@ class LogtoClient {
343
333
  const cachedJwkSet = new remoteJwkSet.CachedRemoteJwkSet(new URL(jwksUri), this.adapter);
344
334
  return async (...args) => cachedJwkSet.getKey(...args);
345
335
  }
346
- async #getAccessToken(resource) {
347
- if (!(await this.getIdToken())) {
336
+ async #getAccessToken(resource, organizationId) {
337
+ if (!(await this.isAuthenticated())) {
348
338
  throw new errors.LogtoClientError('not_authenticated');
349
339
  }
350
- const accessTokenKey = index$2.buildAccessTokenKey(resource);
340
+ const accessTokenKey = index$2.buildAccessTokenKey(resource, organizationId);
351
341
  const accessToken = this.accessTokenMap.get(accessTokenKey);
352
342
  if (accessToken && accessToken.expiresAt > Date.now() / 1000) {
353
343
  return accessToken.token;
@@ -359,9 +349,51 @@ class LogtoClient {
359
349
  /**
360
350
  * Need to fetch a new access token using refresh token.
361
351
  */
362
- return this.getAccessTokenByRefreshToken(resource);
352
+ return this.getAccessTokenByRefreshToken(resource, organizationId);
353
+ }
354
+ async #getOrganizationToken(organizationId) {
355
+ if (!this.logtoConfig.scopes?.includes(js.UserScope.Organizations)) {
356
+ throw new errors.LogtoClientError('missing_scope_organizations');
357
+ }
358
+ return this.#getAccessToken(undefined, organizationId);
359
+ }
360
+ async #handleSignInCallback(callbackUri) {
361
+ const { requester } = this.adapter;
362
+ const signInSession = await this.getSignInSession();
363
+ if (!signInSession) {
364
+ throw new errors.LogtoClientError('sign_in_session.not_found');
365
+ }
366
+ const { redirectUri, state, codeVerifier } = signInSession;
367
+ const code = js.verifyAndParseCodeFromCallbackUri(callbackUri, redirectUri, state);
368
+ // NOTE: Will add scope to accessTokenKey when needed. (Linear issue LOG-1589)
369
+ const accessTokenKey = index$2.buildAccessTokenKey();
370
+ const { appId: clientId } = this.logtoConfig;
371
+ const { tokenEndpoint } = await this.getOidcConfig();
372
+ const requestedAt = Math.round(Date.now() / 1000);
373
+ const { idToken, refreshToken, accessToken, scope, expiresIn } = await js.fetchTokenByAuthorizationCode({
374
+ clientId,
375
+ tokenEndpoint,
376
+ redirectUri,
377
+ codeVerifier,
378
+ code,
379
+ }, requester);
380
+ await this.verifyIdToken(idToken);
381
+ await this.setRefreshToken(refreshToken ?? null);
382
+ await this.setIdToken(idToken);
383
+ this.accessTokenMap.set(accessTokenKey, {
384
+ token: accessToken,
385
+ scope,
386
+ /** The `expiresAt` variable provides an approximate estimation of the actual `exp` property
387
+ * in the token claims. It is utilized by the client to determine if the cached access token
388
+ * has expired and when a new access token should be requested.
389
+ */
390
+ expiresAt: requestedAt + expiresIn,
391
+ });
392
+ await this.saveAccessTokenMap();
393
+ await this.setSignInSession(null);
363
394
  }
364
395
  }
396
+ /* eslint-enable max-lines */
365
397
 
366
398
  Object.defineProperty(exports, 'LogtoError', {
367
399
  enumerable: true,
@@ -379,6 +411,10 @@ Object.defineProperty(exports, 'Prompt', {
379
411
  enumerable: true,
380
412
  get: function () { return js.Prompt; }
381
413
  });
414
+ Object.defineProperty(exports, 'ReservedResource', {
415
+ enumerable: true,
416
+ get: function () { return js.ReservedResource; }
417
+ });
382
418
  Object.defineProperty(exports, 'ReservedScope', {
383
419
  enumerable: true,
384
420
  get: function () { return js.ReservedScope; }
@@ -387,9 +423,22 @@ Object.defineProperty(exports, 'UserScope', {
387
423
  enumerable: true,
388
424
  get: function () { return js.UserScope; }
389
425
  });
426
+ Object.defineProperty(exports, 'buildOrganizationUrn', {
427
+ enumerable: true,
428
+ get: function () { return js.buildOrganizationUrn; }
429
+ });
430
+ Object.defineProperty(exports, 'getOrganizationIdFromUrn', {
431
+ enumerable: true,
432
+ get: function () { return js.getOrganizationIdFromUrn; }
433
+ });
434
+ Object.defineProperty(exports, 'organizationUrnPrefix', {
435
+ enumerable: true,
436
+ get: function () { return js.organizationUrnPrefix; }
437
+ });
390
438
  exports.LogtoClientError = errors.LogtoClientError;
391
439
  exports.isLogtoAccessTokenMap = index.isLogtoAccessTokenMap;
392
440
  exports.isLogtoSignInSessionItem = index.isLogtoSignInSessionItem;
441
+ exports.normalizeLogtoConfig = index.normalizeLogtoConfig;
393
442
  Object.defineProperty(exports, 'CacheKey', {
394
443
  enumerable: true,
395
444
  get: function () { return types.CacheKey; }
package/lib/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { type IdTokenClaims, type UserInfoResponse, type InteractionMode, type AccessTokenClaims } from '@logto/js';
1
+ import { type IdTokenClaims, type UserInfoResponse, type InteractionMode, type AccessTokenClaims, type OidcConfigResponse } from '@logto/js';
2
2
  import { type Nullable } from '@silverhand/essentials';
3
3
  import { type JWTVerifyGetKey } from 'jose';
4
4
  import { ClientAdapterInstance, type ClientAdapter } from './adapter/index.js';
5
5
  import type { AccessToken, LogtoConfig, LogtoSignInSessionItem } from './types/index.js';
6
6
  export type { IdTokenClaims, LogtoErrorCode, UserInfoResponse, InteractionMode } from '@logto/js';
7
- export { LogtoError, LogtoRequestError, OidcError, Prompt, ReservedScope, UserScope, } from '@logto/js';
7
+ export { LogtoError, LogtoRequestError, OidcError, Prompt, ReservedScope, ReservedResource, UserScope, organizationUrnPrefix, buildOrganizationUrn, getOrganizationIdFromUrn, } from '@logto/js';
8
8
  export * from './errors.js';
9
9
  export type { Storage, StorageKey, ClientAdapter } from './adapter/index.js';
10
10
  export { PersistKey, CacheKey } from './adapter/index.js';
@@ -20,17 +20,13 @@ export * from './types/index.js';
20
20
  export default class LogtoClient {
21
21
  #private;
22
22
  readonly logtoConfig: LogtoConfig;
23
- readonly getOidcConfig: (this: unknown) => Promise<import("@silverhand/essentials").KeysToCamelCase<{
24
- authorization_endpoint: string;
25
- token_endpoint: string;
26
- userinfo_endpoint: string;
27
- end_session_endpoint: string;
28
- revocation_endpoint: string;
29
- jwks_uri: string;
30
- issuer: string;
31
- }>>;
32
23
  /**
33
- * Get the access token from the storage.
24
+ * Get the OIDC configuration from the discovery endpoint. This method will
25
+ * only fetch the configuration once and cache the result.
26
+ */
27
+ readonly getOidcConfig: () => Promise<OidcConfigResponse>;
28
+ /**
29
+ * Get the access token from the storage with refresh strategy.
34
30
  *
35
31
  * - If the access token has expired, it will try to fetch a new one using the Refresh Token.
36
32
  * - If there's an ongoing Promise to fetch the access token, it will return the Promise.
@@ -43,7 +39,30 @@ export default class LogtoClient {
43
39
  * @returns The access token string.
44
40
  * @throws LogtoClientError if the user is not authenticated.
45
41
  */
46
- readonly getAccessToken: (this: unknown, resource?: string | undefined) => Promise<string>;
42
+ readonly getAccessToken: (this: unknown, resource?: string | undefined, organizationId?: string | undefined) => Promise<string>;
43
+ /**
44
+ * Get the access token for the specified organization from the storage with refresh strategy.
45
+ *
46
+ * Scope {@link UserScope.Organizations} is required in the config to use organization-related
47
+ * methods.
48
+ *
49
+ * @param organizationId The ID of the organization that the access token is granted for.
50
+ * @returns The access token string.
51
+ * @throws LogtoClientError if the user is not authenticated.
52
+ * @remarks
53
+ * It uses the same refresh strategy as {@link getAccessToken}.
54
+ */
55
+ readonly getOrganizationToken: (this: unknown, organizationId: string) => Promise<string>;
56
+ /**
57
+ * Handle the sign-in callback by parsing the authorization code from the
58
+ * callback URI and exchanging it for the tokens.
59
+ *
60
+ * @param callbackUri The callback URI, including the search params, that the user is redirected to after the sign-in flow is completed.
61
+ * The origin and pathname of this URI must match the origin and pathname of the redirect URI specified in {@link signIn}.
62
+ * In many cases you'll probably end up passing `window.location.href` as the argument to this function.
63
+ * @throws LogtoClientError if the sign-in session is not found.
64
+ */
65
+ readonly handleSignInCallback: (this: unknown, callbackUri: string) => Promise<void>;
47
66
  protected readonly getJwtVerifyGetKey: (...args: unknown[]) => Promise<JWTVerifyGetKey>;
48
67
  protected readonly adapter: ClientAdapterInstance;
49
68
  protected readonly accessTokenMap: Map<string, AccessToken>;
@@ -73,6 +92,12 @@ export default class LogtoClient {
73
92
  * resource, as specified in the Logto Console.
74
93
  */
75
94
  getAccessTokenClaims(resource?: string): Promise<AccessTokenClaims>;
95
+ /**
96
+ * Get the organization token claims for the specified organization.
97
+ *
98
+ * @param organizationId The ID of the organization that the access token is granted for.
99
+ */
100
+ getOrganizationTokenClaims(organizationId: string): Promise<AccessTokenClaims>;
76
101
  /**
77
102
  * Get the user information from the Userinfo Endpoint.
78
103
  *
@@ -110,16 +135,6 @@ export default class LogtoClient {
110
135
  * @param url The current URL.
111
136
  */
112
137
  isSignInRedirected(url: string): Promise<boolean>;
113
- /**
114
- * Handle the sign-in callback by parsing the authorization code from the
115
- * callback URI and exchanging it for the tokens.
116
- *
117
- * @param callbackUri The callback URI, including the search params, that the user is redirected to after the sign-in flow is completed.
118
- * The origin and pathname of this URI must match the origin and pathname of the redirect URI specified in {@link signIn}.
119
- * In many cases you'll probably end up passing `window.location.href` as the argument to this function.
120
- * @throws LogtoClientError if the sign-in session is not found.
121
- */
122
- handleSignInCallback(callbackUri: string): Promise<void>;
123
138
  /**
124
139
  * Start the sign-out flow with the specified redirect URI. The URI must be
125
140
  * registered in the Logto Console.
package/lib/index.js CHANGED
@@ -1,16 +1,17 @@
1
- import { Prompt, withDefaultScopes, decodeIdToken, decodeAccessToken, fetchUserInfo, generateSignInUri, verifyAndParseCodeFromCallbackUri, fetchTokenByAuthorizationCode, revoke, generateSignOutUri, fetchTokenByRefreshToken, verifyIdToken, fetchOidcConfig } from '@logto/js';
2
- export { LogtoError, LogtoRequestError, OidcError, Prompt, ReservedScope, UserScope } from '@logto/js';
1
+ import { decodeIdToken, decodeAccessToken, fetchUserInfo, generateSignInUri, revoke, generateSignOutUri, fetchTokenByRefreshToken, verifyIdToken, fetchOidcConfig, UserScope, verifyAndParseCodeFromCallbackUri, fetchTokenByAuthorizationCode } from '@logto/js';
2
+ export { LogtoError, LogtoRequestError, OidcError, Prompt, ReservedResource, ReservedScope, UserScope, buildOrganizationUrn, getOrganizationIdFromUrn, organizationUrnPrefix } from '@logto/js';
3
3
  import { createRemoteJWKSet } from 'jose';
4
4
  import { ClientAdapterInstance } from './adapter/index.js';
5
5
  import { LogtoClientError } from './errors.js';
6
6
  import { CachedRemoteJwkSet } from './remote-jwk-set.js';
7
- import { isLogtoSignInSessionItem, isLogtoAccessTokenMap } from './types/index.js';
7
+ import { normalizeLogtoConfig, isLogtoSignInSessionItem, isLogtoAccessTokenMap } from './types/index.js';
8
8
  import { buildAccessTokenKey, getDiscoveryEndpoint } from './utils/index.js';
9
9
  import { memoize } from './utils/memoize.js';
10
10
  import { once } from './utils/once.js';
11
11
  import { PersistKey, CacheKey } from './adapter/types.js';
12
12
  export { createRequester } from './utils/requester.js';
13
13
 
14
+ /* eslint-disable max-lines */
14
15
  /**
15
16
  * The Logto base client class that provides the essential methods for
16
17
  * interacting with the Logto server.
@@ -20,9 +21,13 @@ export { createRequester } from './utils/requester.js';
20
21
  */
21
22
  class LogtoClient {
22
23
  constructor(logtoConfig, adapter) {
23
- this.getOidcConfig = memoize(this.#getOidcConfig);
24
24
  /**
25
- * Get the access token from the storage.
25
+ * Get the OIDC configuration from the discovery endpoint. This method will
26
+ * only fetch the configuration once and cache the result.
27
+ */
28
+ this.getOidcConfig = once(this.#getOidcConfig);
29
+ /**
30
+ * Get the access token from the storage with refresh strategy.
26
31
  *
27
32
  * - If the access token has expired, it will try to fetch a new one using the Refresh Token.
28
33
  * - If there's an ongoing Promise to fetch the access token, it will return the Promise.
@@ -36,13 +41,32 @@ class LogtoClient {
36
41
  * @throws LogtoClientError if the user is not authenticated.
37
42
  */
38
43
  this.getAccessToken = memoize(this.#getAccessToken);
44
+ /**
45
+ * Get the access token for the specified organization from the storage with refresh strategy.
46
+ *
47
+ * Scope {@link UserScope.Organizations} is required in the config to use organization-related
48
+ * methods.
49
+ *
50
+ * @param organizationId The ID of the organization that the access token is granted for.
51
+ * @returns The access token string.
52
+ * @throws LogtoClientError if the user is not authenticated.
53
+ * @remarks
54
+ * It uses the same refresh strategy as {@link getAccessToken}.
55
+ */
56
+ this.getOrganizationToken = memoize(this.#getOrganizationToken);
57
+ /**
58
+ * Handle the sign-in callback by parsing the authorization code from the
59
+ * callback URI and exchanging it for the tokens.
60
+ *
61
+ * @param callbackUri The callback URI, including the search params, that the user is redirected to after the sign-in flow is completed.
62
+ * The origin and pathname of this URI must match the origin and pathname of the redirect URI specified in {@link signIn}.
63
+ * In many cases you'll probably end up passing `window.location.href` as the argument to this function.
64
+ * @throws LogtoClientError if the sign-in session is not found.
65
+ */
66
+ this.handleSignInCallback = memoize(this.#handleSignInCallback);
39
67
  this.getJwtVerifyGetKey = once(this.#getJwtVerifyGetKey);
40
68
  this.accessTokenMap = new Map();
41
- this.logtoConfig = {
42
- ...logtoConfig,
43
- prompt: logtoConfig.prompt ?? Prompt.Consent,
44
- scopes: withDefaultScopes(logtoConfig.scopes).split(' '),
45
- };
69
+ this.logtoConfig = normalizeLogtoConfig(logtoConfig);
46
70
  this.adapter = new ClientAdapterInstance(adapter);
47
71
  void this.loadAccessTokenMap();
48
72
  }
@@ -86,6 +110,15 @@ class LogtoClient {
86
110
  const accessToken = await this.getAccessToken(resource);
87
111
  return decodeAccessToken(accessToken);
88
112
  }
113
+ /**
114
+ * Get the organization token claims for the specified organization.
115
+ *
116
+ * @param organizationId The ID of the organization that the access token is granted for.
117
+ */
118
+ async getOrganizationTokenClaims(organizationId) {
119
+ const accessToken = await this.getOrganizationToken(organizationId);
120
+ return decodeAccessToken(accessToken);
121
+ }
89
122
  /**
90
123
  * Get the user information from the Userinfo Endpoint.
91
124
  *
@@ -161,50 +194,6 @@ class LogtoClient {
161
194
  const { origin, pathname } = new URL(url);
162
195
  return `${origin}${pathname}` === redirectUri;
163
196
  }
164
- /**
165
- * Handle the sign-in callback by parsing the authorization code from the
166
- * callback URI and exchanging it for the tokens.
167
- *
168
- * @param callbackUri The callback URI, including the search params, that the user is redirected to after the sign-in flow is completed.
169
- * The origin and pathname of this URI must match the origin and pathname of the redirect URI specified in {@link signIn}.
170
- * In many cases you'll probably end up passing `window.location.href` as the argument to this function.
171
- * @throws LogtoClientError if the sign-in session is not found.
172
- */
173
- async handleSignInCallback(callbackUri) {
174
- const { requester } = this.adapter;
175
- const signInSession = await this.getSignInSession();
176
- if (!signInSession) {
177
- throw new LogtoClientError('sign_in_session.not_found');
178
- }
179
- const { redirectUri, state, codeVerifier } = signInSession;
180
- const code = verifyAndParseCodeFromCallbackUri(callbackUri, redirectUri, state);
181
- // NOTE: Will add scope to accessTokenKey when needed. (Linear issue LOG-1589)
182
- const accessTokenKey = buildAccessTokenKey();
183
- const { appId: clientId } = this.logtoConfig;
184
- const { tokenEndpoint } = await this.getOidcConfig();
185
- const requestedAt = Math.round(Date.now() / 1000);
186
- const { idToken, refreshToken, accessToken, scope, expiresIn } = await fetchTokenByAuthorizationCode({
187
- clientId,
188
- tokenEndpoint,
189
- redirectUri,
190
- codeVerifier,
191
- code,
192
- }, requester);
193
- await this.verifyIdToken(idToken);
194
- await this.setRefreshToken(refreshToken ?? null);
195
- await this.setIdToken(idToken);
196
- this.accessTokenMap.set(accessTokenKey, {
197
- token: accessToken,
198
- scope,
199
- /** The `expiresAt` variable provides an approximate estimation of the actual `exp` property
200
- * in the token claims. It is utilized by the client to determine if the cached access token
201
- * has expired and when a new access token should be requested.
202
- */
203
- expiresAt: requestedAt + expiresIn,
204
- });
205
- await this.saveAccessTokenMap();
206
- await this.setSignInSession(null);
207
- }
208
197
  /**
209
198
  * Start the sign-out flow with the specified redirect URI. The URI must be
210
199
  * registered in the Logto Console.
@@ -260,12 +249,12 @@ class LogtoClient {
260
249
  async setRefreshToken(value) {
261
250
  return this.adapter.setStorageItem(PersistKey.RefreshToken, value);
262
251
  }
263
- async getAccessTokenByRefreshToken(resource) {
252
+ async getAccessTokenByRefreshToken(resource, organizationId) {
264
253
  const currentRefreshToken = await this.getRefreshToken();
265
254
  if (!currentRefreshToken) {
266
255
  throw new LogtoClientError('not_authenticated');
267
256
  }
268
- const accessTokenKey = buildAccessTokenKey(resource);
257
+ const accessTokenKey = buildAccessTokenKey(resource, organizationId);
269
258
  const { appId: clientId } = this.logtoConfig;
270
259
  const { tokenEndpoint } = await this.getOidcConfig();
271
260
  const requestedAt = Math.round(Date.now() / 1000);
@@ -274,6 +263,7 @@ class LogtoClient {
274
263
  tokenEndpoint,
275
264
  refreshToken: currentRefreshToken,
276
265
  resource,
266
+ organizationId,
277
267
  }, this.adapter.requester);
278
268
  this.accessTokenMap.set(accessTokenKey, {
279
269
  token: accessToken,
@@ -340,11 +330,11 @@ class LogtoClient {
340
330
  const cachedJwkSet = new CachedRemoteJwkSet(new URL(jwksUri), this.adapter);
341
331
  return async (...args) => cachedJwkSet.getKey(...args);
342
332
  }
343
- async #getAccessToken(resource) {
344
- if (!(await this.getIdToken())) {
333
+ async #getAccessToken(resource, organizationId) {
334
+ if (!(await this.isAuthenticated())) {
345
335
  throw new LogtoClientError('not_authenticated');
346
336
  }
347
- const accessTokenKey = buildAccessTokenKey(resource);
337
+ const accessTokenKey = buildAccessTokenKey(resource, organizationId);
348
338
  const accessToken = this.accessTokenMap.get(accessTokenKey);
349
339
  if (accessToken && accessToken.expiresAt > Date.now() / 1000) {
350
340
  return accessToken.token;
@@ -356,8 +346,50 @@ class LogtoClient {
356
346
  /**
357
347
  * Need to fetch a new access token using refresh token.
358
348
  */
359
- return this.getAccessTokenByRefreshToken(resource);
349
+ return this.getAccessTokenByRefreshToken(resource, organizationId);
350
+ }
351
+ async #getOrganizationToken(organizationId) {
352
+ if (!this.logtoConfig.scopes?.includes(UserScope.Organizations)) {
353
+ throw new LogtoClientError('missing_scope_organizations');
354
+ }
355
+ return this.#getAccessToken(undefined, organizationId);
356
+ }
357
+ async #handleSignInCallback(callbackUri) {
358
+ const { requester } = this.adapter;
359
+ const signInSession = await this.getSignInSession();
360
+ if (!signInSession) {
361
+ throw new LogtoClientError('sign_in_session.not_found');
362
+ }
363
+ const { redirectUri, state, codeVerifier } = signInSession;
364
+ const code = verifyAndParseCodeFromCallbackUri(callbackUri, redirectUri, state);
365
+ // NOTE: Will add scope to accessTokenKey when needed. (Linear issue LOG-1589)
366
+ const accessTokenKey = buildAccessTokenKey();
367
+ const { appId: clientId } = this.logtoConfig;
368
+ const { tokenEndpoint } = await this.getOidcConfig();
369
+ const requestedAt = Math.round(Date.now() / 1000);
370
+ const { idToken, refreshToken, accessToken, scope, expiresIn } = await fetchTokenByAuthorizationCode({
371
+ clientId,
372
+ tokenEndpoint,
373
+ redirectUri,
374
+ codeVerifier,
375
+ code,
376
+ }, requester);
377
+ await this.verifyIdToken(idToken);
378
+ await this.setRefreshToken(refreshToken ?? null);
379
+ await this.setIdToken(idToken);
380
+ this.accessTokenMap.set(accessTokenKey, {
381
+ token: accessToken,
382
+ scope,
383
+ /** The `expiresAt` variable provides an approximate estimation of the actual `exp` property
384
+ * in the token claims. It is utilized by the client to determine if the cached access token
385
+ * has expired and when a new access token should be requested.
386
+ */
387
+ expiresAt: requestedAt + expiresIn,
388
+ });
389
+ await this.saveAccessTokenMap();
390
+ await this.setSignInSession(null);
360
391
  }
361
392
  }
393
+ /* eslint-enable max-lines */
362
394
 
363
- export { CacheKey, LogtoClientError, PersistKey, LogtoClient as default, isLogtoAccessTokenMap, isLogtoSignInSessionItem };
395
+ export { CacheKey, LogtoClientError, PersistKey, LogtoClient as default, isLogtoAccessTokenMap, isLogtoSignInSessionItem, normalizeLogtoConfig };
package/lib/mock.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /// <reference types="jest" />
2
- import { Prompt } from '@logto/js';
2
+ import { type OidcConfigResponse, Prompt } from '@logto/js';
3
3
  import { type Nullable } from '@silverhand/essentials';
4
4
  import type { Storage } from './adapter/index.js';
5
5
  import type { AccessToken, LogtoConfig, LogtoSignInSessionItem } from './index.js';
@@ -66,23 +66,15 @@ export declare const createAdapters: (withCache?: boolean) => {
66
66
  generateCodeVerifier: jest.Mock<string, [], any>;
67
67
  generateState: jest.Mock<string, [], any>;
68
68
  };
69
- export declare const createClient: (prompt?: Prompt, storage?: MockedStorage, withCache?: boolean) => LogtoClientWithAccessors;
69
+ export declare const createClient: (prompt?: Prompt, storage?: MockedStorage, withCache?: boolean, scopes?: string[]) => LogtoClientWithAccessors;
70
70
  /**
71
71
  * Make protected fields accessible for test
72
72
  */
73
73
  export declare class LogtoClientWithAccessors extends LogtoClient {
74
- runGetOidcConfig(): Promise<import("@silverhand/essentials").KeysToCamelCase<{
75
- authorization_endpoint: string;
76
- token_endpoint: string;
77
- userinfo_endpoint: string;
78
- end_session_endpoint: string;
79
- revocation_endpoint: string;
80
- jwks_uri: string;
81
- issuer: string;
82
- }>>;
74
+ runGetOidcConfig(): Promise<OidcConfigResponse>;
83
75
  runGetJwtVerifyGetKey(): Promise<import("jose").JWTVerifyGetKey>;
84
76
  getLogtoConfig(): Nullable<LogtoConfig>;
85
- getSignInSessionItem(): Promise<Nullable<LogtoSignInSessionItem>>;
77
+ getSignInSession(): Promise<Nullable<LogtoSignInSessionItem>>;
86
78
  setSignInSessionItem(item: Nullable<LogtoSignInSessionItem>): Promise<void>;
87
79
  getAccessTokenMap(): Map<string, AccessToken>;
88
80
  }
@@ -1,7 +1,28 @@
1
1
  'use strict';
2
2
 
3
3
  var js = require('@logto/js');
4
+ var essentials = require('@silverhand/essentials');
4
5
 
6
+ /**
7
+ * Normalize the Logto client configuration per the following rules:
8
+ *
9
+ * - Add default scopes (`openid`, `offline_access` and `profile`) if not provided.
10
+ * - Add {@link ReservedResource.Organization} to resources if {@link UserScope.Organizations} is included in scopes.
11
+ *
12
+ * @param config The Logto client configuration to be normalized.
13
+ * @returns The normalized Logto client configuration.
14
+ */
15
+ const normalizeLogtoConfig = (config) => {
16
+ const { prompt = js.Prompt.Consent, scopes = [], resources, ...rest } = config;
17
+ return {
18
+ ...rest,
19
+ prompt,
20
+ scopes: js.withDefaultScopes(scopes).split(' '),
21
+ resources: scopes.includes(js.UserScope.Organizations)
22
+ ? essentials.deduplicate([...(resources ?? []), js.ReservedResource.Organization])
23
+ : resources,
24
+ };
25
+ };
5
26
  const isLogtoSignInSessionItem = (data) => {
6
27
  if (!js.isArbitraryObject(data)) {
7
28
  return false;
@@ -24,3 +45,4 @@ const isLogtoAccessTokenMap = (data) => {
24
45
 
25
46
  exports.isLogtoAccessTokenMap = isLogtoAccessTokenMap;
26
47
  exports.isLogtoSignInSessionItem = isLogtoSignInSessionItem;
48
+ exports.normalizeLogtoConfig = normalizeLogtoConfig;
@@ -1,4 +1,4 @@
1
- import type { Prompt } from '@logto/js';
1
+ import { Prompt } from '@logto/js';
2
2
  /** The configuration object for the Logto client. */
3
3
  export type LogtoConfig = {
4
4
  /**
@@ -40,6 +40,16 @@ export type LogtoConfig = {
40
40
  */
41
41
  prompt?: Prompt;
42
42
  };
43
+ /**
44
+ * Normalize the Logto client configuration per the following rules:
45
+ *
46
+ * - Add default scopes (`openid`, `offline_access` and `profile`) if not provided.
47
+ * - Add {@link ReservedResource.Organization} to resources if {@link UserScope.Organizations} is included in scopes.
48
+ *
49
+ * @param config The Logto client configuration to be normalized.
50
+ * @returns The normalized Logto client configuration.
51
+ */
52
+ export declare const normalizeLogtoConfig: (config: LogtoConfig) => LogtoConfig;
43
53
  export type AccessToken = {
44
54
  /** The access token string. */
45
55
  token: string;
@@ -1,5 +1,26 @@
1
- import { isArbitraryObject } from '@logto/js';
1
+ import { Prompt, withDefaultScopes, UserScope, ReservedResource, isArbitraryObject } from '@logto/js';
2
+ import { deduplicate } from '@silverhand/essentials';
2
3
 
4
+ /**
5
+ * Normalize the Logto client configuration per the following rules:
6
+ *
7
+ * - Add default scopes (`openid`, `offline_access` and `profile`) if not provided.
8
+ * - Add {@link ReservedResource.Organization} to resources if {@link UserScope.Organizations} is included in scopes.
9
+ *
10
+ * @param config The Logto client configuration to be normalized.
11
+ * @returns The normalized Logto client configuration.
12
+ */
13
+ const normalizeLogtoConfig = (config) => {
14
+ const { prompt = Prompt.Consent, scopes = [], resources, ...rest } = config;
15
+ return {
16
+ ...rest,
17
+ prompt,
18
+ scopes: withDefaultScopes(scopes).split(' '),
19
+ resources: scopes.includes(UserScope.Organizations)
20
+ ? deduplicate([...(resources ?? []), ReservedResource.Organization])
21
+ : resources,
22
+ };
23
+ };
3
24
  const isLogtoSignInSessionItem = (data) => {
4
25
  if (!isArbitraryObject(data)) {
5
26
  return false;
@@ -20,4 +41,4 @@ const isLogtoAccessTokenMap = (data) => {
20
41
  });
21
42
  };
22
43
 
23
- export { isLogtoAccessTokenMap, isLogtoSignInSessionItem };
44
+ export { isLogtoAccessTokenMap, isLogtoSignInSessionItem, normalizeLogtoConfig };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,8 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  var js = require('@logto/js');
4
+ var essentials = require('@silverhand/essentials');
4
5
 
5
- const buildAccessTokenKey = (resource = '', scopes = []) => `${scopes.slice().sort().join(' ')}@${resource}`;
6
+ const buildAccessTokenKey = (resource = '', organizationId, scopes = []) => `${scopes.slice().sort().join(' ')}@${resource}${essentials.conditionalString(organizationId && `#${organizationId}`)}`;
6
7
  const getDiscoveryEndpoint = (endpoint) => new URL(js.discoveryPath, endpoint).toString();
7
8
 
8
9
  exports.buildAccessTokenKey = buildAccessTokenKey;
@@ -1,3 +1,3 @@
1
1
  export * from './requester.js';
2
- export declare const buildAccessTokenKey: (resource?: string, scopes?: string[]) => string;
2
+ export declare const buildAccessTokenKey: (resource?: string, organizationId?: string, scopes?: string[]) => string;
3
3
  export declare const getDiscoveryEndpoint: (endpoint: string) => string;
@@ -1,6 +1,7 @@
1
1
  import { discoveryPath } from '@logto/js';
2
+ import { conditionalString } from '@silverhand/essentials';
2
3
 
3
- const buildAccessTokenKey = (resource = '', scopes = []) => `${scopes.slice().sort().join(' ')}@${resource}`;
4
+ const buildAccessTokenKey = (resource = '', organizationId, scopes = []) => `${scopes.slice().sort().join(' ')}@${resource}${conditionalString(organizationId && `#${organizationId}`)}`;
4
5
  const getDiscoveryEndpoint = (endpoint) => new URL(discoveryPath, endpoint).toString();
5
6
 
6
7
  export { buildAccessTokenKey, getDiscoveryEndpoint };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logto/client",
3
- "version": "2.2.4",
3
+ "version": "2.3.1",
4
4
  "type": "module",
5
5
  "main": "./lib/index.cjs",
6
6
  "module": "./lib/index.js",
@@ -21,10 +21,10 @@
21
21
  "directory": "packages/client"
22
22
  },
23
23
  "dependencies": {
24
- "@logto/js": "^2.1.3",
24
+ "@logto/js": "^3.0.0",
25
25
  "@silverhand/essentials": "^2.6.2",
26
26
  "camelcase-keys": "^7.0.1",
27
- "jose": "^4.13.2"
27
+ "jose": "^5.0.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@silverhand/eslint-config": "^4.0.1",