@logto/client 2.1.0 → 2.2.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.
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ var essentials = require('@silverhand/essentials');
4
+ var types = require('./types.cjs');
5
+
6
+ class ClientAdapterInstance {
7
+ /* END OF IMPLEMENTATION */
8
+ constructor(adapter) {
9
+ // eslint-disable-next-line @silverhand/fp/no-mutating-assign
10
+ Object.assign(this, adapter);
11
+ }
12
+ async setStorageItem(key, value) {
13
+ if (!value) {
14
+ await this.storage.removeItem(key);
15
+ return;
16
+ }
17
+ await this.storage.setItem(key, value);
18
+ }
19
+ /**
20
+ * Try to get the string value from the cache and parse as JSON.
21
+ * Return the parsed value if it is an object, return `undefined` otherwise.
22
+ *
23
+ * @param key The cache key to get value from.
24
+ */
25
+ async getCachedObject(key) {
26
+ const cached = await essentials.trySafe(async () => {
27
+ const data = await this.unstable_cache?.getItem(key);
28
+ // It's actually `unknown`
29
+ // eslint-disable-next-line no-restricted-syntax
30
+ return essentials.conditional(data && JSON.parse(data));
31
+ });
32
+ if (cached && typeof cached === 'object') {
33
+ // Trust cache for now
34
+ // eslint-disable-next-line no-restricted-syntax
35
+ return cached;
36
+ }
37
+ }
38
+ /**
39
+ * Try to get the value from the cache first, if it doesn't exist in cache,
40
+ * run the getter function and store the result into cache.
41
+ *
42
+ * @param key The cache key to get value from.
43
+ */
44
+ async getWithCache(key, getter) {
45
+ const cached = await this.getCachedObject(key);
46
+ if (cached) {
47
+ return cached;
48
+ }
49
+ const result = await getter();
50
+ await this.unstable_cache?.setItem(key, JSON.stringify(result));
51
+ return result;
52
+ }
53
+ }
54
+
55
+ Object.defineProperty(exports, 'CacheKey', {
56
+ enumerable: true,
57
+ get: function () { return types.CacheKey; }
58
+ });
59
+ Object.defineProperty(exports, 'PersistKey', {
60
+ enumerable: true,
61
+ get: function () { return types.PersistKey; }
62
+ });
63
+ exports.ClientAdapterInstance = ClientAdapterInstance;
@@ -0,0 +1,29 @@
1
+ import { type Requester } from '@logto/js';
2
+ import { type Nullable } from '@silverhand/essentials';
3
+ import { type CacheKey, type Navigate, type PersistKey, type Storage, type StorageKey, type ClientAdapter, type InferStorageKey } from './types.js';
4
+ export declare class ClientAdapterInstance implements ClientAdapter {
5
+ requester: Requester;
6
+ storage: Storage<StorageKey | PersistKey>;
7
+ unstable_cache?: Storage<CacheKey> | undefined;
8
+ navigate: Navigate;
9
+ generateState: () => string;
10
+ generateCodeVerifier: () => string;
11
+ generateCodeChallenge: (codeVerifier: string) => Promise<string>;
12
+ constructor(adapter: ClientAdapter);
13
+ setStorageItem(key: InferStorageKey<typeof this.storage>, value: Nullable<string>): Promise<void>;
14
+ /**
15
+ * Try to get the string value from the cache and parse as JSON.
16
+ * Return the parsed value if it is an object, return `undefined` otherwise.
17
+ *
18
+ * @param key The cache key to get value from.
19
+ */
20
+ getCachedObject<T>(key: CacheKey): Promise<T | undefined>;
21
+ /**
22
+ * Try to get the value from the cache first, if it doesn't exist in cache,
23
+ * run the getter function and store the result into cache.
24
+ *
25
+ * @param key The cache key to get value from.
26
+ */
27
+ getWithCache<T>(key: CacheKey, getter: () => Promise<T>): Promise<T>;
28
+ }
29
+ export * from './types.js';
@@ -0,0 +1,53 @@
1
+ import { trySafe, conditional } from '@silverhand/essentials';
2
+ export { CacheKey, PersistKey } from './types.js';
3
+
4
+ class ClientAdapterInstance {
5
+ /* END OF IMPLEMENTATION */
6
+ constructor(adapter) {
7
+ // eslint-disable-next-line @silverhand/fp/no-mutating-assign
8
+ Object.assign(this, adapter);
9
+ }
10
+ async setStorageItem(key, value) {
11
+ if (!value) {
12
+ await this.storage.removeItem(key);
13
+ return;
14
+ }
15
+ await this.storage.setItem(key, value);
16
+ }
17
+ /**
18
+ * Try to get the string value from the cache and parse as JSON.
19
+ * Return the parsed value if it is an object, return `undefined` otherwise.
20
+ *
21
+ * @param key The cache key to get value from.
22
+ */
23
+ async getCachedObject(key) {
24
+ const cached = await trySafe(async () => {
25
+ const data = await this.unstable_cache?.getItem(key);
26
+ // It's actually `unknown`
27
+ // eslint-disable-next-line no-restricted-syntax
28
+ return conditional(data && JSON.parse(data));
29
+ });
30
+ if (cached && typeof cached === 'object') {
31
+ // Trust cache for now
32
+ // eslint-disable-next-line no-restricted-syntax
33
+ return cached;
34
+ }
35
+ }
36
+ /**
37
+ * Try to get the value from the cache first, if it doesn't exist in cache,
38
+ * run the getter function and store the result into cache.
39
+ *
40
+ * @param key The cache key to get value from.
41
+ */
42
+ async getWithCache(key, getter) {
43
+ const cached = await this.getCachedObject(key);
44
+ if (cached) {
45
+ return cached;
46
+ }
47
+ const result = await getter();
48
+ await this.unstable_cache?.setItem(key, JSON.stringify(result));
49
+ return result;
50
+ }
51
+ }
52
+
53
+ export { ClientAdapterInstance };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ exports.PersistKey = void 0;
4
+ (function (PersistKey) {
5
+ PersistKey["IdToken"] = "idToken";
6
+ PersistKey["RefreshToken"] = "refreshToken";
7
+ PersistKey["AccessToken"] = "accessToken";
8
+ PersistKey["SignInSession"] = "signInSession";
9
+ })(exports.PersistKey || (exports.PersistKey = {}));
10
+ exports.CacheKey = void 0;
11
+ (function (CacheKey) {
12
+ /**
13
+ * OpenID Configuration endpoint response.
14
+ *
15
+ * @see {@link https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse | OpenID Connect Discovery 1.0}
16
+ */
17
+ CacheKey["OpenidConfig"] = "openidConfiguration";
18
+ /**
19
+ * The content of OpenID Provider's `jwks_uri` (JSON Web Key Set).
20
+ *
21
+ * @see {@link https://openid.net/specs/openid-connect-discovery-1_0-21.html#ProviderMetadata | OpenID Connect Discovery 1.0}
22
+ */
23
+ CacheKey["Jwks"] = "jwks";
24
+ })(exports.CacheKey || (exports.CacheKey = {}));
@@ -0,0 +1,45 @@
1
+ import type { Requester } from '@logto/js';
2
+ import type { Nullable } from '@silverhand/essentials';
3
+ /** @deprecated Use {@link PersistKey} instead. */
4
+ export type StorageKey = 'idToken' | 'refreshToken' | 'accessToken' | 'signInSession';
5
+ export declare enum PersistKey {
6
+ IdToken = "idToken",
7
+ RefreshToken = "refreshToken",
8
+ AccessToken = "accessToken",
9
+ SignInSession = "signInSession"
10
+ }
11
+ export declare enum CacheKey {
12
+ /**
13
+ * OpenID Configuration endpoint response.
14
+ *
15
+ * @see {@link https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse | OpenID Connect Discovery 1.0}
16
+ */
17
+ OpenidConfig = "openidConfiguration",
18
+ /**
19
+ * The content of OpenID Provider's `jwks_uri` (JSON Web Key Set).
20
+ *
21
+ * @see {@link https://openid.net/specs/openid-connect-discovery-1_0-21.html#ProviderMetadata | OpenID Connect Discovery 1.0}
22
+ */
23
+ Jwks = "jwks"
24
+ }
25
+ export type Storage<Keys extends string> = {
26
+ getItem(key: Keys): Promise<Nullable<string>>;
27
+ setItem(key: Keys, value: string): Promise<void>;
28
+ removeItem(key: Keys): Promise<void>;
29
+ };
30
+ export type InferStorageKey<S> = S extends Storage<infer Key> ? Key : never;
31
+ export type Navigate = (url: string) => void;
32
+ export type ClientAdapter = {
33
+ requester: Requester;
34
+ storage: Storage<StorageKey | PersistKey>;
35
+ /**
36
+ * An optional storage for caching well-known data.
37
+ *
38
+ * @see {@link CacheKey}
39
+ */
40
+ unstable_cache?: Storage<CacheKey>;
41
+ navigate: Navigate;
42
+ generateState: () => string;
43
+ generateCodeVerifier: () => string;
44
+ generateCodeChallenge: (codeVerifier: string) => Promise<string>;
45
+ };
@@ -0,0 +1,24 @@
1
+ var PersistKey;
2
+ (function (PersistKey) {
3
+ PersistKey["IdToken"] = "idToken";
4
+ PersistKey["RefreshToken"] = "refreshToken";
5
+ PersistKey["AccessToken"] = "accessToken";
6
+ PersistKey["SignInSession"] = "signInSession";
7
+ })(PersistKey || (PersistKey = {}));
8
+ var CacheKey;
9
+ (function (CacheKey) {
10
+ /**
11
+ * OpenID Configuration endpoint response.
12
+ *
13
+ * @see {@link https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse | OpenID Connect Discovery 1.0}
14
+ */
15
+ CacheKey["OpenidConfig"] = "openidConfiguration";
16
+ /**
17
+ * The content of OpenID Provider's `jwks_uri` (JSON Web Key Set).
18
+ *
19
+ * @see {@link https://openid.net/specs/openid-connect-discovery-1_0-21.html#ProviderMetadata | OpenID Connect Discovery 1.0}
20
+ */
21
+ CacheKey["Jwks"] = "jwks";
22
+ })(CacheKey || (CacheKey = {}));
23
+
24
+ export { CacheKey, PersistKey };
@@ -0,0 +1 @@
1
+ export {};
package/lib/index.cjs CHANGED
@@ -4,23 +4,27 @@ Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
5
  var js = require('@logto/js');
6
6
  var jose = require('jose');
7
+ var index$1 = require('./adapter/index.cjs');
7
8
  var errors = require('./errors.cjs');
9
+ var remoteJwkSet = require('./remote-jwk-set.cjs');
8
10
  var index = require('./types/index.cjs');
9
- var index$1 = require('./utils/index.cjs');
11
+ var index$2 = require('./utils/index.cjs');
12
+ var memoize = require('./utils/memoize.cjs');
10
13
  var once = require('./utils/once.cjs');
14
+ var types = require('./adapter/types.cjs');
11
15
  var requester = require('./utils/requester.cjs');
12
16
 
13
17
  class LogtoClient {
14
18
  constructor(logtoConfig, adapter) {
15
- this.getOidcConfig = once.once(this._getOidcConfig);
16
- this.getJwtVerifyGetKey = once.once(this._getJwtVerifyGetKey);
19
+ this.getOidcConfig = memoize.memoize(this.#getOidcConfig);
20
+ this.getJwtVerifyGetKey = once.once(this.#getJwtVerifyGetKey);
17
21
  this.accessTokenMap = new Map();
18
22
  this.logtoConfig = {
19
23
  ...logtoConfig,
20
24
  prompt: logtoConfig.prompt ?? js.Prompt.Consent,
21
25
  scopes: js.withDefaultScopes(logtoConfig.scopes).split(' '),
22
26
  };
23
- this.adapter = adapter;
27
+ this.adapter = new index$1.ClientAdapterInstance(adapter);
24
28
  void this.loadAccessTokenMap();
25
29
  }
26
30
  async isAuthenticated() {
@@ -36,7 +40,7 @@ class LogtoClient {
36
40
  if (!(await this.getIdToken())) {
37
41
  throw new errors.LogtoClientError('not_authenticated');
38
42
  }
39
- const accessTokenKey = index$1.buildAccessTokenKey(resource);
43
+ const accessTokenKey = index$2.buildAccessTokenKey(resource);
40
44
  const accessToken = this.accessTokenMap.get(accessTokenKey);
41
45
  if (accessToken && accessToken.expiresAt > Date.now() / 1000) {
42
46
  return accessToken.token;
@@ -101,25 +105,38 @@ class LogtoClient {
101
105
  return `${origin}${pathname}` === redirectUri;
102
106
  }
103
107
  async handleSignInCallback(callbackUri) {
104
- const { logtoConfig, adapter } = this;
105
- const { requester } = adapter;
108
+ const { requester } = this.adapter;
106
109
  const signInSession = await this.getSignInSession();
107
110
  if (!signInSession) {
108
111
  throw new errors.LogtoClientError('sign_in_session.not_found');
109
112
  }
110
113
  const { redirectUri, state, codeVerifier } = signInSession;
111
114
  const code = js.verifyAndParseCodeFromCallbackUri(callbackUri, redirectUri, state);
112
- const { appId: clientId } = logtoConfig;
115
+ // NOTE: Will add scope to accessTokenKey when needed. (Linear issue LOG-1589)
116
+ const accessTokenKey = index$2.buildAccessTokenKey();
117
+ const { appId: clientId } = this.logtoConfig;
113
118
  const { tokenEndpoint } = await this.getOidcConfig();
114
- const codeTokenResponse = await js.fetchTokenByAuthorizationCode({
119
+ const requestedAt = Math.round(Date.now() / 1000);
120
+ const { idToken, refreshToken, accessToken, scope, expiresIn } = await js.fetchTokenByAuthorizationCode({
115
121
  clientId,
116
122
  tokenEndpoint,
117
123
  redirectUri,
118
124
  codeVerifier,
119
125
  code,
120
126
  }, requester);
121
- await this.verifyIdToken(codeTokenResponse.idToken);
122
- await this.saveCodeToken(codeTokenResponse);
127
+ await this.verifyIdToken(idToken);
128
+ await this.setRefreshToken(refreshToken ?? null);
129
+ await this.setIdToken(idToken);
130
+ this.accessTokenMap.set(accessTokenKey, {
131
+ token: accessToken,
132
+ scope,
133
+ /** The `expiresAt` variable provides an approximate estimation of the actual `exp` property
134
+ * in the token claims. It is utilized by the client to determine if the cached access token
135
+ * has expired and when a new access token should be requested.
136
+ */
137
+ expiresAt: requestedAt + expiresIn,
138
+ });
139
+ await this.saveAccessTokenMap();
123
140
  await this.setSignInSession(null);
124
141
  }
125
142
  async signOut(postLogoutRedirectUri) {
@@ -156,36 +173,24 @@ class LogtoClient {
156
173
  }
157
174
  return item;
158
175
  }
159
- async setSignInSession(logtoSignInSessionItem) {
160
- if (!logtoSignInSessionItem) {
161
- await this.adapter.storage.removeItem('signInSession');
162
- return;
163
- }
164
- const jsonItem = JSON.stringify(logtoSignInSessionItem);
165
- await this.adapter.storage.setItem('signInSession', jsonItem);
176
+ async setSignInSession(value) {
177
+ return this.adapter.setStorageItem(types.PersistKey.SignInSession, value && JSON.stringify(value));
166
178
  }
167
- async setIdToken(idToken) {
168
- if (!idToken) {
169
- await this.adapter.storage.removeItem('idToken');
170
- return;
171
- }
172
- await this.adapter.storage.setItem('idToken', idToken);
179
+ async setIdToken(value) {
180
+ return this.adapter.setStorageItem(types.PersistKey.IdToken, value);
173
181
  }
174
- async setRefreshToken(refreshToken) {
175
- if (!refreshToken) {
176
- await this.adapter.storage.removeItem('refreshToken');
177
- return;
178
- }
179
- await this.adapter.storage.setItem('refreshToken', refreshToken);
182
+ async setRefreshToken(value) {
183
+ return this.adapter.setStorageItem(types.PersistKey.RefreshToken, value);
180
184
  }
181
185
  async getAccessTokenByRefreshToken(resource) {
182
186
  const currentRefreshToken = await this.getRefreshToken();
183
187
  if (!currentRefreshToken) {
184
188
  throw new errors.LogtoClientError('not_authenticated');
185
189
  }
186
- const accessTokenKey = index$1.buildAccessTokenKey(resource);
190
+ const accessTokenKey = index$2.buildAccessTokenKey(resource);
187
191
  const { appId: clientId } = this.logtoConfig;
188
192
  const { tokenEndpoint } = await this.getOidcConfig();
193
+ const requestedAt = Math.round(Date.now() / 1000);
189
194
  const { accessToken, refreshToken, idToken, scope, expiresIn } = await js.fetchTokenByRefreshToken({
190
195
  clientId,
191
196
  tokenEndpoint,
@@ -195,7 +200,11 @@ class LogtoClient {
195
200
  this.accessTokenMap.set(accessTokenKey, {
196
201
  token: accessToken,
197
202
  scope,
198
- expiresAt: Math.round(Date.now() / 1000) + expiresIn,
203
+ /** The `expiresAt` variable provides an approximate estimation of the actual `exp` property
204
+ * in the token claims. It is utilized by the client to determine if the cached access token
205
+ * has expired and when a new access token should be requested.
206
+ */
207
+ expiresAt: requestedAt + expiresIn,
199
208
  });
200
209
  await this.saveAccessTokenMap();
201
210
  await this.setRefreshToken(refreshToken);
@@ -205,30 +214,12 @@ class LogtoClient {
205
214
  }
206
215
  return accessToken;
207
216
  }
208
- async _getOidcConfig() {
209
- const { endpoint } = this.logtoConfig;
210
- const discoveryEndpoint = index$1.getDiscoveryEndpoint(endpoint);
211
- return js.fetchOidcConfig(discoveryEndpoint, this.adapter.requester);
212
- }
213
- async _getJwtVerifyGetKey() {
214
- const { jwksUri } = await this.getOidcConfig();
215
- return jose.createRemoteJWKSet(new URL(jwksUri));
216
- }
217
217
  async verifyIdToken(idToken) {
218
218
  const { appId } = this.logtoConfig;
219
219
  const { issuer } = await this.getOidcConfig();
220
220
  const jwtVerifyGetKey = await this.getJwtVerifyGetKey();
221
221
  await js.verifyIdToken(idToken, appId, issuer, jwtVerifyGetKey);
222
222
  }
223
- async saveCodeToken({ refreshToken, idToken, scope, accessToken, expiresIn, }) {
224
- await this.setRefreshToken(refreshToken ?? null);
225
- await this.setIdToken(idToken);
226
- // NOTE: Will add scope to accessTokenKey when needed. (Linear issue LOG-1589)
227
- const accessTokenKey = index$1.buildAccessTokenKey();
228
- const expiresAt = Date.now() / 1000 + expiresIn;
229
- this.accessTokenMap.set(accessTokenKey, { token: accessToken, scope, expiresAt });
230
- await this.saveAccessTokenMap();
231
- }
232
223
  async saveAccessTokenMap() {
233
224
  const data = {};
234
225
  for (const [key, accessToken] of this.accessTokenMap.entries()) {
@@ -256,6 +247,19 @@ class LogtoClient {
256
247
  console.warn(error);
257
248
  }
258
249
  }
250
+ async #getOidcConfig() {
251
+ return this.adapter.getWithCache(types.CacheKey.OpenidConfig, async () => {
252
+ return js.fetchOidcConfig(index$2.getDiscoveryEndpoint(this.logtoConfig.endpoint), this.adapter.requester);
253
+ });
254
+ }
255
+ async #getJwtVerifyGetKey() {
256
+ const { jwksUri } = await this.getOidcConfig();
257
+ if (!this.adapter.unstable_cache) {
258
+ return jose.createRemoteJWKSet(new URL(jwksUri));
259
+ }
260
+ const cachedJwkSet = new remoteJwkSet.CachedRemoteJwkSet(new URL(jwksUri), this.adapter);
261
+ return async (...args) => cachedJwkSet.getKey(...args);
262
+ }
259
263
  }
260
264
 
261
265
  Object.defineProperty(exports, 'LogtoError', {
@@ -285,5 +289,13 @@ Object.defineProperty(exports, 'UserScope', {
285
289
  exports.LogtoClientError = errors.LogtoClientError;
286
290
  exports.isLogtoAccessTokenMap = index.isLogtoAccessTokenMap;
287
291
  exports.isLogtoSignInSessionItem = index.isLogtoSignInSessionItem;
292
+ Object.defineProperty(exports, 'CacheKey', {
293
+ enumerable: true,
294
+ get: function () { return types.CacheKey; }
295
+ });
296
+ Object.defineProperty(exports, 'PersistKey', {
297
+ enumerable: true,
298
+ get: function () { return types.PersistKey; }
299
+ });
288
300
  exports.createRequester = requester.createRequester;
289
301
  exports.default = LogtoClient;
@@ -0,0 +1 @@
1
+ export {};
package/lib/index.d.ts CHANGED
@@ -1,18 +1,29 @@
1
1
  import { type IdTokenClaims, type UserInfoResponse, type InteractionMode, type AccessTokenClaims } from '@logto/js';
2
- import type { Nullable } from '@silverhand/essentials';
3
- import type { ClientAdapter } from './adapter.js';
2
+ import { type Nullable } from '@silverhand/essentials';
3
+ import { type JWTVerifyGetKey } from 'jose';
4
+ import { ClientAdapterInstance, type ClientAdapter } from './adapter/index.js';
4
5
  import type { AccessToken, LogtoConfig, LogtoSignInSessionItem } from './types/index.js';
5
6
  export type { IdTokenClaims, LogtoErrorCode, UserInfoResponse, InteractionMode } from '@logto/js';
6
7
  export { LogtoError, OidcError, Prompt, LogtoRequestError, ReservedScope, UserScope, } from '@logto/js';
7
8
  export * from './errors.js';
8
- export type { Storage, StorageKey, ClientAdapter } from './adapter.js';
9
+ export type { Storage, StorageKey, ClientAdapter } from './adapter/index.js';
10
+ export { PersistKey, CacheKey } from './adapter/index.js';
9
11
  export { createRequester } from './utils/index.js';
10
12
  export * from './types/index.js';
11
13
  export default class LogtoClient {
14
+ #private;
12
15
  protected readonly logtoConfig: LogtoConfig;
13
- protected readonly getOidcConfig: typeof this._getOidcConfig;
14
- protected readonly getJwtVerifyGetKey: (...args: unknown[]) => Promise<(protectedHeader?: import("jose").JWSHeaderParameters | undefined, token?: import("jose").FlattenedJWSInput | undefined) => Promise<import("jose").KeyLike>>;
15
- protected readonly adapter: ClientAdapter;
16
+ protected readonly getOidcConfig: (this: unknown) => Promise<import("@silverhand/essentials").KeysToCamelCase<{
17
+ authorization_endpoint: string;
18
+ token_endpoint: string;
19
+ userinfo_endpoint: string;
20
+ end_session_endpoint: string;
21
+ revocation_endpoint: string;
22
+ jwks_uri: string;
23
+ issuer: string;
24
+ }>>;
25
+ protected readonly getJwtVerifyGetKey: (...args: unknown[]) => Promise<JWTVerifyGetKey>;
26
+ protected readonly adapter: ClientAdapterInstance;
16
27
  protected readonly accessTokenMap: Map<string, AccessToken>;
17
28
  constructor(logtoConfig: LogtoConfig, adapter: ClientAdapter);
18
29
  isAuthenticated(): Promise<boolean>;
@@ -27,14 +38,11 @@ export default class LogtoClient {
27
38
  handleSignInCallback(callbackUri: string): Promise<void>;
28
39
  signOut(postLogoutRedirectUri?: string): Promise<void>;
29
40
  protected getSignInSession(): Promise<Nullable<LogtoSignInSessionItem>>;
30
- protected setSignInSession(logtoSignInSessionItem: Nullable<LogtoSignInSessionItem>): Promise<void>;
41
+ protected setSignInSession(value: Nullable<LogtoSignInSessionItem>): Promise<void>;
31
42
  private setIdToken;
32
43
  private setRefreshToken;
33
44
  private getAccessTokenByRefreshToken;
34
- private _getOidcConfig;
35
- private _getJwtVerifyGetKey;
36
45
  private verifyIdToken;
37
- private saveCodeToken;
38
46
  private saveAccessTokenMap;
39
47
  private loadAccessTokenMap;
40
48
  }
package/lib/index.js CHANGED
@@ -1,23 +1,27 @@
1
- import { Prompt, withDefaultScopes, decodeIdToken, decodeAccessToken, fetchUserInfo, generateSignInUri, verifyAndParseCodeFromCallbackUri, fetchTokenByAuthorizationCode, revoke, generateSignOutUri, fetchTokenByRefreshToken, fetchOidcConfig, verifyIdToken } from '@logto/js';
1
+ import { Prompt, withDefaultScopes, decodeIdToken, decodeAccessToken, fetchUserInfo, generateSignInUri, verifyAndParseCodeFromCallbackUri, fetchTokenByAuthorizationCode, revoke, generateSignOutUri, fetchTokenByRefreshToken, verifyIdToken, fetchOidcConfig } from '@logto/js';
2
2
  export { LogtoError, LogtoRequestError, OidcError, Prompt, ReservedScope, UserScope } from '@logto/js';
3
3
  import { createRemoteJWKSet } from 'jose';
4
+ import { ClientAdapterInstance } from './adapter/index.js';
4
5
  import { LogtoClientError } from './errors.js';
6
+ import { CachedRemoteJwkSet } from './remote-jwk-set.js';
5
7
  import { isLogtoSignInSessionItem, isLogtoAccessTokenMap } from './types/index.js';
6
8
  import { buildAccessTokenKey, getDiscoveryEndpoint } from './utils/index.js';
9
+ import { memoize } from './utils/memoize.js';
7
10
  import { once } from './utils/once.js';
11
+ import { PersistKey, CacheKey } from './adapter/types.js';
8
12
  export { createRequester } from './utils/requester.js';
9
13
 
10
14
  class LogtoClient {
11
15
  constructor(logtoConfig, adapter) {
12
- this.getOidcConfig = once(this._getOidcConfig);
13
- this.getJwtVerifyGetKey = once(this._getJwtVerifyGetKey);
16
+ this.getOidcConfig = memoize(this.#getOidcConfig);
17
+ this.getJwtVerifyGetKey = once(this.#getJwtVerifyGetKey);
14
18
  this.accessTokenMap = new Map();
15
19
  this.logtoConfig = {
16
20
  ...logtoConfig,
17
21
  prompt: logtoConfig.prompt ?? Prompt.Consent,
18
22
  scopes: withDefaultScopes(logtoConfig.scopes).split(' '),
19
23
  };
20
- this.adapter = adapter;
24
+ this.adapter = new ClientAdapterInstance(adapter);
21
25
  void this.loadAccessTokenMap();
22
26
  }
23
27
  async isAuthenticated() {
@@ -98,25 +102,38 @@ class LogtoClient {
98
102
  return `${origin}${pathname}` === redirectUri;
99
103
  }
100
104
  async handleSignInCallback(callbackUri) {
101
- const { logtoConfig, adapter } = this;
102
- const { requester } = adapter;
105
+ const { requester } = this.adapter;
103
106
  const signInSession = await this.getSignInSession();
104
107
  if (!signInSession) {
105
108
  throw new LogtoClientError('sign_in_session.not_found');
106
109
  }
107
110
  const { redirectUri, state, codeVerifier } = signInSession;
108
111
  const code = verifyAndParseCodeFromCallbackUri(callbackUri, redirectUri, state);
109
- const { appId: clientId } = logtoConfig;
112
+ // NOTE: Will add scope to accessTokenKey when needed. (Linear issue LOG-1589)
113
+ const accessTokenKey = buildAccessTokenKey();
114
+ const { appId: clientId } = this.logtoConfig;
110
115
  const { tokenEndpoint } = await this.getOidcConfig();
111
- const codeTokenResponse = await fetchTokenByAuthorizationCode({
116
+ const requestedAt = Math.round(Date.now() / 1000);
117
+ const { idToken, refreshToken, accessToken, scope, expiresIn } = await fetchTokenByAuthorizationCode({
112
118
  clientId,
113
119
  tokenEndpoint,
114
120
  redirectUri,
115
121
  codeVerifier,
116
122
  code,
117
123
  }, requester);
118
- await this.verifyIdToken(codeTokenResponse.idToken);
119
- await this.saveCodeToken(codeTokenResponse);
124
+ await this.verifyIdToken(idToken);
125
+ await this.setRefreshToken(refreshToken ?? null);
126
+ await this.setIdToken(idToken);
127
+ this.accessTokenMap.set(accessTokenKey, {
128
+ token: accessToken,
129
+ scope,
130
+ /** The `expiresAt` variable provides an approximate estimation of the actual `exp` property
131
+ * in the token claims. It is utilized by the client to determine if the cached access token
132
+ * has expired and when a new access token should be requested.
133
+ */
134
+ expiresAt: requestedAt + expiresIn,
135
+ });
136
+ await this.saveAccessTokenMap();
120
137
  await this.setSignInSession(null);
121
138
  }
122
139
  async signOut(postLogoutRedirectUri) {
@@ -153,27 +170,14 @@ class LogtoClient {
153
170
  }
154
171
  return item;
155
172
  }
156
- async setSignInSession(logtoSignInSessionItem) {
157
- if (!logtoSignInSessionItem) {
158
- await this.adapter.storage.removeItem('signInSession');
159
- return;
160
- }
161
- const jsonItem = JSON.stringify(logtoSignInSessionItem);
162
- await this.adapter.storage.setItem('signInSession', jsonItem);
173
+ async setSignInSession(value) {
174
+ return this.adapter.setStorageItem(PersistKey.SignInSession, value && JSON.stringify(value));
163
175
  }
164
- async setIdToken(idToken) {
165
- if (!idToken) {
166
- await this.adapter.storage.removeItem('idToken');
167
- return;
168
- }
169
- await this.adapter.storage.setItem('idToken', idToken);
176
+ async setIdToken(value) {
177
+ return this.adapter.setStorageItem(PersistKey.IdToken, value);
170
178
  }
171
- async setRefreshToken(refreshToken) {
172
- if (!refreshToken) {
173
- await this.adapter.storage.removeItem('refreshToken');
174
- return;
175
- }
176
- await this.adapter.storage.setItem('refreshToken', refreshToken);
179
+ async setRefreshToken(value) {
180
+ return this.adapter.setStorageItem(PersistKey.RefreshToken, value);
177
181
  }
178
182
  async getAccessTokenByRefreshToken(resource) {
179
183
  const currentRefreshToken = await this.getRefreshToken();
@@ -183,6 +187,7 @@ class LogtoClient {
183
187
  const accessTokenKey = buildAccessTokenKey(resource);
184
188
  const { appId: clientId } = this.logtoConfig;
185
189
  const { tokenEndpoint } = await this.getOidcConfig();
190
+ const requestedAt = Math.round(Date.now() / 1000);
186
191
  const { accessToken, refreshToken, idToken, scope, expiresIn } = await fetchTokenByRefreshToken({
187
192
  clientId,
188
193
  tokenEndpoint,
@@ -192,7 +197,11 @@ class LogtoClient {
192
197
  this.accessTokenMap.set(accessTokenKey, {
193
198
  token: accessToken,
194
199
  scope,
195
- expiresAt: Math.round(Date.now() / 1000) + expiresIn,
200
+ /** The `expiresAt` variable provides an approximate estimation of the actual `exp` property
201
+ * in the token claims. It is utilized by the client to determine if the cached access token
202
+ * has expired and when a new access token should be requested.
203
+ */
204
+ expiresAt: requestedAt + expiresIn,
196
205
  });
197
206
  await this.saveAccessTokenMap();
198
207
  await this.setRefreshToken(refreshToken);
@@ -202,30 +211,12 @@ class LogtoClient {
202
211
  }
203
212
  return accessToken;
204
213
  }
205
- async _getOidcConfig() {
206
- const { endpoint } = this.logtoConfig;
207
- const discoveryEndpoint = getDiscoveryEndpoint(endpoint);
208
- return fetchOidcConfig(discoveryEndpoint, this.adapter.requester);
209
- }
210
- async _getJwtVerifyGetKey() {
211
- const { jwksUri } = await this.getOidcConfig();
212
- return createRemoteJWKSet(new URL(jwksUri));
213
- }
214
214
  async verifyIdToken(idToken) {
215
215
  const { appId } = this.logtoConfig;
216
216
  const { issuer } = await this.getOidcConfig();
217
217
  const jwtVerifyGetKey = await this.getJwtVerifyGetKey();
218
218
  await verifyIdToken(idToken, appId, issuer, jwtVerifyGetKey);
219
219
  }
220
- async saveCodeToken({ refreshToken, idToken, scope, accessToken, expiresIn, }) {
221
- await this.setRefreshToken(refreshToken ?? null);
222
- await this.setIdToken(idToken);
223
- // NOTE: Will add scope to accessTokenKey when needed. (Linear issue LOG-1589)
224
- const accessTokenKey = buildAccessTokenKey();
225
- const expiresAt = Date.now() / 1000 + expiresIn;
226
- this.accessTokenMap.set(accessTokenKey, { token: accessToken, scope, expiresAt });
227
- await this.saveAccessTokenMap();
228
- }
229
220
  async saveAccessTokenMap() {
230
221
  const data = {};
231
222
  for (const [key, accessToken] of this.accessTokenMap.entries()) {
@@ -253,6 +244,19 @@ class LogtoClient {
253
244
  console.warn(error);
254
245
  }
255
246
  }
247
+ async #getOidcConfig() {
248
+ return this.adapter.getWithCache(CacheKey.OpenidConfig, async () => {
249
+ return fetchOidcConfig(getDiscoveryEndpoint(this.logtoConfig.endpoint), this.adapter.requester);
250
+ });
251
+ }
252
+ async #getJwtVerifyGetKey() {
253
+ const { jwksUri } = await this.getOidcConfig();
254
+ if (!this.adapter.unstable_cache) {
255
+ return createRemoteJWKSet(new URL(jwksUri));
256
+ }
257
+ const cachedJwkSet = new CachedRemoteJwkSet(new URL(jwksUri), this.adapter);
258
+ return async (...args) => cachedJwkSet.getKey(...args);
259
+ }
256
260
  }
257
261
 
258
- export { LogtoClientError, LogtoClient as default, isLogtoAccessTokenMap, isLogtoSignInSessionItem };
262
+ export { CacheKey, LogtoClientError, PersistKey, LogtoClient as default, isLogtoAccessTokenMap, isLogtoSignInSessionItem };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
package/lib/mock.d.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /// <reference types="jest" />
2
2
  import { Prompt } from '@logto/js';
3
- import type { Nullable } from '@silverhand/essentials';
4
- import type { Storage } from './adapter.js';
3
+ import { type Nullable } from '@silverhand/essentials';
4
+ import type { Storage } from './adapter/index.js';
5
5
  import type { AccessToken, LogtoConfig, LogtoSignInSessionItem } from './index.js';
6
6
  import LogtoClient from './index.js';
7
7
  export declare const appId = "app_id_value";
8
8
  export declare const endpoint = "https://logto.dev";
9
- export declare class MockedStorage implements Storage {
9
+ export declare class MockedStorage implements Storage<string> {
10
10
  private storage;
11
11
  constructor(values?: Record<string, string>);
12
12
  getItem(key: string): Promise<string | null>;
@@ -33,6 +33,15 @@ export declare const accessToken = "access_token_value";
33
33
  export declare const refreshToken = "new_refresh_token_value";
34
34
  export declare const idToken = "id_token_value";
35
35
  export declare const currentUnixTimeStamp: number;
36
+ export declare const mockFetchOidcConfig: (delay?: number) => jest.Mock<Promise<{
37
+ authorizationEndpoint: string;
38
+ tokenEndpoint: string;
39
+ userinfoEndpoint: string;
40
+ endSessionEndpoint: string;
41
+ revocationEndpoint: string;
42
+ jwksUri: string;
43
+ issuer: string;
44
+ }>, [], any>;
36
45
  export declare const fetchOidcConfig: jest.Mock<Promise<{
37
46
  authorizationEndpoint: string;
38
47
  tokenEndpoint: string;
@@ -48,19 +57,30 @@ export declare const navigate: jest.Mock<any, any, any>;
48
57
  export declare const generateCodeChallenge: jest.Mock<Promise<string>, [], any>;
49
58
  export declare const generateCodeVerifier: jest.Mock<string, [], any>;
50
59
  export declare const generateState: jest.Mock<string, [], any>;
51
- export declare const createAdapters: () => {
60
+ export declare const createAdapters: (withCache?: boolean) => {
52
61
  requester: jest.Mock<any, any, any>;
53
62
  storage: MockedStorage;
63
+ unstable_cache: import("@silverhand/essentials").Optional<MockedStorage>;
54
64
  navigate: jest.Mock<any, any, any>;
55
65
  generateCodeChallenge: jest.Mock<Promise<string>, [], any>;
56
66
  generateCodeVerifier: jest.Mock<string, [], any>;
57
67
  generateState: jest.Mock<string, [], any>;
58
68
  };
59
- export declare const createClient: (prompt?: Prompt, storage?: MockedStorage) => LogtoClient;
69
+ export declare const createClient: (prompt?: Prompt, storage?: MockedStorage, withCache?: boolean) => LogtoClientWithAccessors;
60
70
  /**
61
- * Make LogtoClient.signInSession accessible for test
71
+ * Make protected fields accessible for test
62
72
  */
63
- export declare class LogtoClientSignInSessionAccessor extends LogtoClient {
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
+ }>>;
83
+ runGetJwtVerifyGetKey(): Promise<import("jose").JWTVerifyGetKey>;
64
84
  getLogtoConfig(): Nullable<LogtoConfig>;
65
85
  getSignInSessionItem(): Promise<Nullable<LogtoSignInSessionItem>>;
66
86
  setSignInSessionItem(item: Nullable<LogtoSignInSessionItem>): Promise<void>;
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ var essentials = require('@silverhand/essentials');
4
+ var jose = require('jose');
5
+ var types = require('./adapter/types.cjs');
6
+
7
+ // Edited from jose's internal util `isJWKSLike`
8
+ function isJwkSetLike(jwkSet) {
9
+ return Boolean(jwkSet &&
10
+ typeof jwkSet === 'object' &&
11
+ 'keys' in jwkSet &&
12
+ Array.isArray(jwkSet.keys) &&
13
+ jwkSet.keys.every((element) => essentials.isObject(element)));
14
+ }
15
+ class CachedRemoteJwkSet {
16
+ constructor(url, adapter) {
17
+ this.url = url;
18
+ this.adapter = adapter;
19
+ if (!adapter.unstable_cache) {
20
+ throw new Error("No cache found in the client adapter. Use `createRemoteJWKSet()` from 'jose' instead.");
21
+ }
22
+ }
23
+ async getKey(...args) {
24
+ if (!this.jwkSet) {
25
+ this.jwkSet = await this.#load();
26
+ }
27
+ try {
28
+ return await this.#getLocalKey(...args);
29
+ }
30
+ catch (error) {
31
+ // Jose does not export the error definition
32
+ // Found in https://github.com/panva/jose/blob/d5b3cb672736112b1e1e31ac4d5e9cd641675206/src/util/errors.ts#L347
33
+ if (error instanceof Error && 'code' in error && error.code === 'ERR_JWKS_NO_MATCHING_KEY') {
34
+ this.jwkSet = await this.#load();
35
+ return this.#getLocalKey(...args);
36
+ }
37
+ throw error;
38
+ }
39
+ }
40
+ async #load() {
41
+ return this.adapter.getWithCache(types.CacheKey.Jwks, async () => {
42
+ const controller = new AbortController();
43
+ const response = await fetch(this.url, { signal: controller.signal, redirect: 'manual' });
44
+ if (!response.ok) {
45
+ throw new Error('Expected OK from the JSON Web Key Set HTTP response');
46
+ }
47
+ const json = await response.json();
48
+ if (!isJwkSetLike(json)) {
49
+ throw new Error('JSON Web Key Set malformed');
50
+ }
51
+ return json;
52
+ });
53
+ }
54
+ async #getLocalKey(...args) {
55
+ if (!this.jwkSet) {
56
+ throw new Error('No local JWK Set found.');
57
+ }
58
+ return jose.createLocalJWKSet(this.jwkSet)(...args);
59
+ }
60
+ }
61
+
62
+ exports.CachedRemoteJwkSet = CachedRemoteJwkSet;
@@ -0,0 +1,10 @@
1
+ import { type JSONWebKeySet, type JWTVerifyGetKey } from 'jose';
2
+ import { type ClientAdapterInstance } from './adapter/index.js';
3
+ export declare class CachedRemoteJwkSet {
4
+ #private;
5
+ readonly url: URL;
6
+ private readonly adapter;
7
+ protected jwkSet?: JSONWebKeySet;
8
+ constructor(url: URL, adapter: ClientAdapterInstance);
9
+ getKey(...args: Parameters<JWTVerifyGetKey>): Promise<import("jose").KeyLike>;
10
+ }
@@ -0,0 +1,60 @@
1
+ import { isObject } from '@silverhand/essentials';
2
+ import { createLocalJWKSet } from 'jose';
3
+ import { CacheKey } from './adapter/types.js';
4
+
5
+ // Edited from jose's internal util `isJWKSLike`
6
+ function isJwkSetLike(jwkSet) {
7
+ return Boolean(jwkSet &&
8
+ typeof jwkSet === 'object' &&
9
+ 'keys' in jwkSet &&
10
+ Array.isArray(jwkSet.keys) &&
11
+ jwkSet.keys.every((element) => isObject(element)));
12
+ }
13
+ class CachedRemoteJwkSet {
14
+ constructor(url, adapter) {
15
+ this.url = url;
16
+ this.adapter = adapter;
17
+ if (!adapter.unstable_cache) {
18
+ throw new Error("No cache found in the client adapter. Use `createRemoteJWKSet()` from 'jose' instead.");
19
+ }
20
+ }
21
+ async getKey(...args) {
22
+ if (!this.jwkSet) {
23
+ this.jwkSet = await this.#load();
24
+ }
25
+ try {
26
+ return await this.#getLocalKey(...args);
27
+ }
28
+ catch (error) {
29
+ // Jose does not export the error definition
30
+ // Found in https://github.com/panva/jose/blob/d5b3cb672736112b1e1e31ac4d5e9cd641675206/src/util/errors.ts#L347
31
+ if (error instanceof Error && 'code' in error && error.code === 'ERR_JWKS_NO_MATCHING_KEY') {
32
+ this.jwkSet = await this.#load();
33
+ return this.#getLocalKey(...args);
34
+ }
35
+ throw error;
36
+ }
37
+ }
38
+ async #load() {
39
+ return this.adapter.getWithCache(CacheKey.Jwks, async () => {
40
+ const controller = new AbortController();
41
+ const response = await fetch(this.url, { signal: controller.signal, redirect: 'manual' });
42
+ if (!response.ok) {
43
+ throw new Error('Expected OK from the JSON Web Key Set HTTP response');
44
+ }
45
+ const json = await response.json();
46
+ if (!isJwkSetLike(json)) {
47
+ throw new Error('JSON Web Key Set malformed');
48
+ }
49
+ return json;
50
+ });
51
+ }
52
+ async #getLocalKey(...args) {
53
+ if (!this.jwkSet) {
54
+ throw new Error('No local JWK Set found.');
55
+ }
56
+ return createLocalJWKSet(this.jwkSet)(...args);
57
+ }
58
+ }
59
+
60
+ export { CachedRemoteJwkSet };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+
3
+ function memoize(run) {
4
+ const promiseCache = new Map();
5
+ const memoized = async function (...args) {
6
+ const promiseKey = args[0];
7
+ const cachedPromise = promiseCache.get(promiseKey);
8
+ if (cachedPromise) {
9
+ return cachedPromise;
10
+ }
11
+ const promise = (async () => {
12
+ try {
13
+ return await run.apply(this, args);
14
+ }
15
+ finally {
16
+ promiseCache.delete(promiseKey);
17
+ }
18
+ })();
19
+ promiseCache.set(promiseKey, promise);
20
+ return promise;
21
+ };
22
+ return memoized;
23
+ }
24
+
25
+ exports.memoize = memoize;
@@ -0,0 +1 @@
1
+ export declare function memoize<Args extends unknown[], Return>(run: (...args: Args) => Promise<Return>): (this: unknown, ...args: Args) => Promise<Return>;
@@ -0,0 +1,23 @@
1
+ function memoize(run) {
2
+ const promiseCache = new Map();
3
+ const memoized = async function (...args) {
4
+ const promiseKey = args[0];
5
+ const cachedPromise = promiseCache.get(promiseKey);
6
+ if (cachedPromise) {
7
+ return cachedPromise;
8
+ }
9
+ const promise = (async () => {
10
+ try {
11
+ return await run.apply(this, args);
12
+ }
13
+ finally {
14
+ promiseCache.delete(promiseKey);
15
+ }
16
+ })();
17
+ promiseCache.set(promiseKey, promise);
18
+ return promise;
19
+ };
20
+ return memoized;
21
+ }
22
+
23
+ export { memoize };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logto/client",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "type": "module",
5
5
  "main": "./lib/index.cjs",
6
6
  "module": "./lib/index.js",
@@ -27,18 +27,18 @@
27
27
  "jose": "^4.13.2"
28
28
  },
29
29
  "devDependencies": {
30
- "@silverhand/eslint-config": "^3.0.1",
31
- "@silverhand/ts-config": "^3.0.0",
30
+ "@silverhand/eslint-config": "^4.0.1",
31
+ "@silverhand/ts-config": "^4.0.0",
32
32
  "@swc/core": "^1.3.50",
33
33
  "@swc/jest": "^0.2.24",
34
34
  "@types/jest": "^29.5.0",
35
35
  "@types/node": "^18.0.0",
36
- "eslint": "^8.38.0",
36
+ "eslint": "^8.44.0",
37
37
  "jest": "^29.5.0",
38
38
  "jest-matcher-specific-error": "^1.0.0",
39
39
  "lint-staged": "^13.0.0",
40
40
  "nock": "^13.3.0",
41
- "prettier": "^2.8.7",
41
+ "prettier": "^3.0.0",
42
42
  "text-encoder": "^0.0.4",
43
43
  "type-fest": "^3.0.0",
44
44
  "typescript": "^5.0.0"
package/lib/adapter.d.ts DELETED
@@ -1,17 +0,0 @@
1
- import type { Requester } from '@logto/js';
2
- import type { Nullable } from '@silverhand/essentials';
3
- export type StorageKey = 'idToken' | 'refreshToken' | 'accessToken' | 'signInSession';
4
- export type Storage = {
5
- getItem(key: StorageKey): Promise<Nullable<string>>;
6
- setItem(key: StorageKey, value: string): Promise<void>;
7
- removeItem(key: StorageKey): Promise<void>;
8
- };
9
- export type Navigate = (url: string) => void;
10
- export type ClientAdapter = {
11
- requester: Requester;
12
- storage: Storage;
13
- navigate: Navigate;
14
- generateState: () => string;
15
- generateCodeVerifier: () => string;
16
- generateCodeChallenge: (codeVerifier: string) => Promise<string>;
17
- };