@logto/client 2.3.3 → 2.5.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.
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+
3
+ var js = require('@logto/js');
4
+ var jose = require('jose');
5
+
6
+ const issuedAtTimeTolerance = 300; // 5 minutes
7
+ const verifyIdToken = async (idToken, clientId, issuer, jwks) => {
8
+ const result = await jose.jwtVerify(idToken, jwks, { audience: clientId, issuer });
9
+ if (Math.abs((result.payload.iat ?? 0) - Date.now() / 1000) > issuedAtTimeTolerance) {
10
+ throw new js.LogtoError('id_token.invalid_iat');
11
+ }
12
+ };
13
+ class DefaultJwtVerifier {
14
+ constructor(client) {
15
+ this.client = client;
16
+ }
17
+ async verifyIdToken(idToken) {
18
+ const { appId } = this.client.logtoConfig;
19
+ const { issuer, jwksUri } = await this.client.getOidcConfig();
20
+ if (!this.getJwtVerifyGetKey) {
21
+ this.getJwtVerifyGetKey = jose.createRemoteJWKSet(new URL(jwksUri));
22
+ }
23
+ await verifyIdToken(idToken, appId, issuer, this.getJwtVerifyGetKey);
24
+ }
25
+ }
26
+
27
+ exports.DefaultJwtVerifier = DefaultJwtVerifier;
28
+ exports.verifyIdToken = verifyIdToken;
@@ -0,0 +1,10 @@
1
+ import type { JWTVerifyGetKey } from 'jose';
2
+ import { type StandardLogtoClient } from '../client.js';
3
+ import { type JwtVerifier } from './types.js';
4
+ export declare const verifyIdToken: (idToken: string, clientId: string, issuer: string, jwks: JWTVerifyGetKey) => Promise<void>;
5
+ export declare class DefaultJwtVerifier implements JwtVerifier {
6
+ protected client: StandardLogtoClient;
7
+ protected getJwtVerifyGetKey?: JWTVerifyGetKey;
8
+ constructor(client: StandardLogtoClient);
9
+ verifyIdToken(idToken: string): Promise<void>;
10
+ }
@@ -0,0 +1,25 @@
1
+ import { LogtoError } from '@logto/js';
2
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
3
+
4
+ const issuedAtTimeTolerance = 300; // 5 minutes
5
+ const verifyIdToken = async (idToken, clientId, issuer, jwks) => {
6
+ const result = await jwtVerify(idToken, jwks, { audience: clientId, issuer });
7
+ if (Math.abs((result.payload.iat ?? 0) - Date.now() / 1000) > issuedAtTimeTolerance) {
8
+ throw new LogtoError('id_token.invalid_iat');
9
+ }
10
+ };
11
+ class DefaultJwtVerifier {
12
+ constructor(client) {
13
+ this.client = client;
14
+ }
15
+ async verifyIdToken(idToken) {
16
+ const { appId } = this.client.logtoConfig;
17
+ const { issuer, jwksUri } = await this.client.getOidcConfig();
18
+ if (!this.getJwtVerifyGetKey) {
19
+ this.getJwtVerifyGetKey = createRemoteJWKSet(new URL(jwksUri));
20
+ }
21
+ await verifyIdToken(idToken, appId, issuer, this.getJwtVerifyGetKey);
22
+ }
23
+ }
24
+
25
+ export { DefaultJwtVerifier, verifyIdToken };
@@ -6,9 +6,9 @@ export declare class ClientAdapterInstance implements ClientAdapter {
6
6
  storage: Storage<StorageKey | PersistKey>;
7
7
  unstable_cache?: Storage<CacheKey> | undefined;
8
8
  navigate: Navigate;
9
- generateState: () => string;
10
- generateCodeVerifier: () => string;
11
- generateCodeChallenge: (codeVerifier: string) => Promise<string>;
9
+ generateState: () => string | Promise<string>;
10
+ generateCodeVerifier: () => string | Promise<string>;
11
+ generateCodeChallenge: (codeVerifier: string) => string | Promise<string>;
12
12
  constructor(adapter: ClientAdapter);
13
13
  setStorageItem(key: InferStorageKey<typeof this.storage>, value: Nullable<string>): Promise<void>;
14
14
  /**
@@ -33,14 +33,43 @@ export type Storage<Keys extends string> = {
33
33
  removeItem(key: Keys): Promise<void>;
34
34
  };
35
35
  export type InferStorageKey<S> = S extends Storage<infer Key> ? Key : never;
36
- /** The navigation function that redirects the user to the specified URL. */
37
- export type Navigate = (url: string) => void | Promise<void>;
36
+ /**
37
+ * The navigation function that redirects the user to the specified URL.
38
+ *
39
+ * @param url The URL to navigate to.
40
+ * @param parameters The parameters for the navigation.
41
+ * @param parameters.redirectUri The redirect URI that the user will be redirected to after the
42
+ * flow is completed. That is, the "redirect URI" for "sign-in" and "post-logout redirect URI" for
43
+ * "sign-out". For the "post-sign-in" navigation, it should be ignored.
44
+ * @param parameters.for The purpose of the navigation. It can be either "sign-in", "sign-out", or
45
+ * "post-sign-in".
46
+ * @remarks Usually, the `redirectUri` parameter can be ignored unless the client needs to pass the
47
+ * redirect scheme or other parameters to the native app, such as `ASWebAuthenticationSession` in
48
+ * iOS.
49
+ */
50
+ export type Navigate = (url: string, parameters: {
51
+ redirectUri?: string;
52
+ for: 'sign-in' | 'sign-out' | 'post-sign-in';
53
+ }) => void | Promise<void>;
54
+ export type JwtVerifier = {
55
+ verifyIdToken(idToken: string): Promise<void>;
56
+ };
38
57
  /**
39
58
  * The adapter object that allows the customizations of the client behavior
40
59
  * for different environments.
41
60
  */
42
61
  export type ClientAdapter = {
62
+ /**
63
+ * The fetch-like function for network requests.
64
+ *
65
+ * @see {@link Requester}
66
+ */
43
67
  requester: Requester;
68
+ /**
69
+ * The storage for storing tokens and sessions. It is usually persistent.
70
+ *
71
+ * @see {@link Requester}
72
+ */
44
73
  storage: Storage<StorageKey | PersistKey>;
45
74
  /**
46
75
  * An optional storage for caching well-known data.
@@ -54,21 +83,21 @@ export type ClientAdapter = {
54
83
  *
55
84
  * @returns The state string.
56
85
  */
57
- generateState: () => string;
86
+ generateState: () => string | Promise<string>;
58
87
  /**
59
88
  * The function that generates a random code verifier string for PKCE.
60
89
  *
61
- * @see {@link https://www.rfc-editor.org/rfc/rfc7636.html| RFC 7636}
90
+ * @see {@link https://www.rfc-editor.org/rfc/rfc7636.html | RFC 7636}
62
91
  * @returns The code verifier string.
63
92
  */
64
- generateCodeVerifier: () => string;
93
+ generateCodeVerifier: () => string | Promise<string>;
65
94
  /**
66
95
  * The function that generates a code challenge string based on the code verifier
67
96
  * for PKCE.
68
97
  *
69
- * @see {@link https://www.rfc-editor.org/rfc/rfc7636.html| RFC 7636}
98
+ * @see {@link https://www.rfc-editor.org/rfc/rfc7636.html | RFC 7636}
70
99
  * @param codeVerifier The code verifier string.
71
100
  * @returns The code challenge string.
72
101
  */
73
- generateCodeChallenge: (codeVerifier: string) => Promise<string>;
102
+ generateCodeChallenge: (codeVerifier: string) => string | Promise<string>;
74
103
  };
package/lib/client.cjs ADDED
@@ -0,0 +1,377 @@
1
+ 'use strict';
2
+
3
+ var js = require('@logto/js');
4
+ var index$1 = require('./adapter/index.cjs');
5
+ var errors = require('./errors.cjs');
6
+ var index = require('./types/index.cjs');
7
+ var index$2 = require('./utils/index.cjs');
8
+ var memoize = require('./utils/memoize.cjs');
9
+ var once = require('./utils/once.cjs');
10
+ var types = require('./adapter/types.cjs');
11
+
12
+ /* eslint-disable max-lines */
13
+ /**
14
+ * The Logto base client class that provides the essential methods for
15
+ * interacting with the Logto server.
16
+ *
17
+ * It also provides an adapter object that allows the customizations of the
18
+ * client behavior for different environments.
19
+ *
20
+ * NOTE: Usually, you would use the `LogtoClient` class instead of `StandardLogtoClient` since it
21
+ * provides the default JWT verifier. However, if you want to avoid the use of `jose` package
22
+ * which is useful for certain environments that don't support native modules like `crypto`, you
23
+ * can use `StandardLogtoClient` and provide your own JWT verifier.
24
+ */
25
+ class StandardLogtoClient {
26
+ constructor(logtoConfig, adapter, buildJwtVerifier) {
27
+ /**
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.
34
+ *
35
+ * - If the access token has expired, it will try to fetch a new one using the Refresh Token.
36
+ * - If there's an ongoing Promise to fetch the access token, it will return the Promise.
37
+ *
38
+ * If you want to get the access token claims, use {@link getAccessTokenClaims} instead.
39
+ *
40
+ * @param resource The resource that the access token is granted for. If not
41
+ * specified, the access token will be used for OpenID Connect or the default
42
+ * resource, as specified in the Logto Console.
43
+ * @returns The access token string.
44
+ * @throws LogtoClientError if the user is not authenticated.
45
+ */
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);
70
+ this.accessTokenMap = new Map();
71
+ this.logtoConfig = index.normalizeLogtoConfig(logtoConfig);
72
+ this.adapter = new index$1.ClientAdapterInstance(adapter);
73
+ this.jwtVerifier = buildJwtVerifier(this);
74
+ void this.loadAccessTokenMap();
75
+ }
76
+ /**
77
+ * Check if the user is authenticated by checking if the ID token exists.
78
+ */
79
+ async isAuthenticated() {
80
+ return Boolean(await this.getIdToken());
81
+ }
82
+ /**
83
+ * Get the Refresh Token from the storage.
84
+ */
85
+ async getRefreshToken() {
86
+ return this.adapter.storage.getItem('refreshToken');
87
+ }
88
+ /**
89
+ * Get the ID Token from the storage. If you want to get the ID Token claims,
90
+ * use {@link getIdTokenClaims} instead.
91
+ */
92
+ async getIdToken() {
93
+ return this.adapter.storage.getItem('idToken');
94
+ }
95
+ /**
96
+ * Get the ID Token claims.
97
+ */
98
+ async getIdTokenClaims() {
99
+ const idToken = await this.getIdToken();
100
+ if (!idToken) {
101
+ throw new errors.LogtoClientError('not_authenticated');
102
+ }
103
+ return js.decodeIdToken(idToken);
104
+ }
105
+ /**
106
+ * Get the access token claims for the specified resource.
107
+ *
108
+ * @param resource The resource that the access token is granted for. If not
109
+ * specified, the access token will be used for OpenID Connect or the default
110
+ * resource, as specified in the Logto Console.
111
+ */
112
+ async getAccessTokenClaims(resource) {
113
+ const accessToken = await this.getAccessToken(resource);
114
+ return js.decodeAccessToken(accessToken);
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
+ }
125
+ /**
126
+ * Get the user information from the Userinfo Endpoint.
127
+ *
128
+ * Note the Userinfo Endpoint will return more claims than the ID Token. See
129
+ * {@link https://docs.logto.io/docs/recipes/integrate-logto/vanilla-js/#fetch-user-information | Fetch user information}
130
+ * for more information.
131
+ *
132
+ * @returns The user information.
133
+ * @throws LogtoClientError if the user is not authenticated.
134
+ */
135
+ async fetchUserInfo() {
136
+ const { userinfoEndpoint } = await this.getOidcConfig();
137
+ const accessToken = await this.getAccessToken();
138
+ if (!accessToken) {
139
+ throw new errors.LogtoClientError('fetch_user_info_failed');
140
+ }
141
+ return js.fetchUserInfo(userinfoEndpoint, accessToken, this.adapter.requester);
142
+ }
143
+ async signIn(options, mode) {
144
+ const { redirectUri: redirectUriUrl, postRedirectUri: postRedirectUriUrl, interactionMode, } = typeof options === 'string' || options instanceof URL
145
+ ? { redirectUri: options, postRedirectUri: undefined, interactionMode: mode }
146
+ : options;
147
+ const redirectUri = redirectUriUrl.toString();
148
+ const postRedirectUri = postRedirectUriUrl?.toString();
149
+ const { appId: clientId, prompt, resources, scopes } = this.logtoConfig;
150
+ const { authorizationEndpoint } = await this.getOidcConfig();
151
+ const [codeVerifier, state] = await Promise.all([
152
+ this.adapter.generateCodeVerifier(),
153
+ this.adapter.generateState(),
154
+ ]);
155
+ const codeChallenge = await this.adapter.generateCodeChallenge(codeVerifier);
156
+ const signInUri = js.generateSignInUri({
157
+ authorizationEndpoint,
158
+ clientId,
159
+ redirectUri: redirectUri.toString(),
160
+ codeChallenge,
161
+ state,
162
+ scopes,
163
+ resources,
164
+ prompt,
165
+ interactionMode,
166
+ });
167
+ await Promise.all([
168
+ this.setSignInSession({ redirectUri, postRedirectUri, codeVerifier, state }),
169
+ this.setRefreshToken(null),
170
+ this.setIdToken(null),
171
+ ]);
172
+ await this.adapter.navigate(signInUri, { redirectUri, for: 'sign-in' });
173
+ }
174
+ /**
175
+ * Check if the user is redirected from the sign-in page by checking if the
176
+ * current URL matches the redirect URI in the sign-in session.
177
+ *
178
+ * If there's no sign-in session, it will return `false`.
179
+ *
180
+ * @param url The current URL.
181
+ */
182
+ async isSignInRedirected(url) {
183
+ const signInSession = await this.getSignInSession();
184
+ if (!signInSession) {
185
+ return false;
186
+ }
187
+ const { redirectUri } = signInSession;
188
+ const { origin, pathname } = new URL(url);
189
+ return `${origin}${pathname}` === redirectUri;
190
+ }
191
+ /**
192
+ * Start the sign-out flow with the specified redirect URI. The URI must be
193
+ * registered in the Logto Console.
194
+ *
195
+ * It will also revoke all the tokens and clean up the storage.
196
+ *
197
+ * The user will be redirected that URI after the sign-out flow is completed.
198
+ * If the `postLogoutRedirectUri` is not specified, the user will be redirected
199
+ * to a default page.
200
+ */
201
+ async signOut(postLogoutRedirectUri) {
202
+ const { appId: clientId } = this.logtoConfig;
203
+ const { endSessionEndpoint, revocationEndpoint } = await this.getOidcConfig();
204
+ const refreshToken = await this.getRefreshToken();
205
+ if (refreshToken) {
206
+ try {
207
+ await js.revoke(revocationEndpoint, clientId, refreshToken, this.adapter.requester);
208
+ }
209
+ catch {
210
+ // Do nothing at this point, as we don't want to break the sign-out flow even if the revocation is failed
211
+ }
212
+ }
213
+ const url = js.generateSignOutUri({
214
+ endSessionEndpoint,
215
+ postLogoutRedirectUri,
216
+ clientId,
217
+ });
218
+ this.accessTokenMap.clear();
219
+ await Promise.all([
220
+ this.setRefreshToken(null),
221
+ this.setIdToken(null),
222
+ this.adapter.storage.removeItem('accessToken'),
223
+ ]);
224
+ await this.adapter.navigate(url, { redirectUri: postLogoutRedirectUri, for: 'sign-out' });
225
+ }
226
+ async getSignInSession() {
227
+ const jsonItem = await this.adapter.storage.getItem('signInSession');
228
+ if (!jsonItem) {
229
+ return null;
230
+ }
231
+ const item = JSON.parse(jsonItem);
232
+ if (!index.isLogtoSignInSessionItem(item)) {
233
+ throw new errors.LogtoClientError('sign_in_session.invalid');
234
+ }
235
+ return item;
236
+ }
237
+ async setSignInSession(value) {
238
+ return this.adapter.setStorageItem(types.PersistKey.SignInSession, value && JSON.stringify(value));
239
+ }
240
+ async setIdToken(value) {
241
+ return this.adapter.setStorageItem(types.PersistKey.IdToken, value);
242
+ }
243
+ async setRefreshToken(value) {
244
+ return this.adapter.setStorageItem(types.PersistKey.RefreshToken, value);
245
+ }
246
+ async getAccessTokenByRefreshToken(resource, organizationId) {
247
+ const currentRefreshToken = await this.getRefreshToken();
248
+ if (!currentRefreshToken) {
249
+ throw new errors.LogtoClientError('not_authenticated');
250
+ }
251
+ const accessTokenKey = index$2.buildAccessTokenKey(resource, organizationId);
252
+ const { appId: clientId } = this.logtoConfig;
253
+ const { tokenEndpoint } = await this.getOidcConfig();
254
+ const requestedAt = Math.round(Date.now() / 1000);
255
+ const { accessToken, refreshToken, idToken, scope, expiresIn } = await js.fetchTokenByRefreshToken({
256
+ clientId,
257
+ tokenEndpoint,
258
+ refreshToken: currentRefreshToken,
259
+ resource,
260
+ organizationId,
261
+ }, this.adapter.requester);
262
+ this.accessTokenMap.set(accessTokenKey, {
263
+ token: accessToken,
264
+ scope,
265
+ /** The `expiresAt` variable provides an approximate estimation of the actual `exp` property
266
+ * in the token claims. It is utilized by the client to determine if the cached access token
267
+ * has expired and when a new access token should be requested.
268
+ */
269
+ expiresAt: requestedAt + expiresIn,
270
+ });
271
+ await this.saveAccessTokenMap();
272
+ if (refreshToken) {
273
+ await this.setRefreshToken(refreshToken);
274
+ }
275
+ if (idToken) {
276
+ await this.jwtVerifier.verifyIdToken(idToken);
277
+ await this.setIdToken(idToken);
278
+ }
279
+ return accessToken;
280
+ }
281
+ async saveAccessTokenMap() {
282
+ const data = {};
283
+ for (const [key, accessToken] of this.accessTokenMap.entries()) {
284
+ // eslint-disable-next-line @silverhand/fp/no-mutation
285
+ data[key] = accessToken;
286
+ }
287
+ await this.adapter.storage.setItem('accessToken', JSON.stringify(data));
288
+ }
289
+ async loadAccessTokenMap() {
290
+ const raw = await this.adapter.storage.getItem('accessToken');
291
+ if (!raw) {
292
+ return;
293
+ }
294
+ try {
295
+ const json = JSON.parse(raw);
296
+ if (!index.isLogtoAccessTokenMap(json)) {
297
+ return;
298
+ }
299
+ this.accessTokenMap.clear();
300
+ for (const [key, accessToken] of Object.entries(json)) {
301
+ this.accessTokenMap.set(key, accessToken);
302
+ }
303
+ }
304
+ catch (error) {
305
+ console.warn(error);
306
+ }
307
+ }
308
+ async #getOidcConfig() {
309
+ return this.adapter.getWithCache(types.CacheKey.OpenidConfig, async () => {
310
+ return js.fetchOidcConfig(index$2.getDiscoveryEndpoint(this.logtoConfig.endpoint), this.adapter.requester);
311
+ });
312
+ }
313
+ async #getAccessToken(resource, organizationId) {
314
+ if (!(await this.isAuthenticated())) {
315
+ throw new errors.LogtoClientError('not_authenticated');
316
+ }
317
+ const accessTokenKey = index$2.buildAccessTokenKey(resource, organizationId);
318
+ const accessToken = this.accessTokenMap.get(accessTokenKey);
319
+ if (accessToken && accessToken.expiresAt > Date.now() / 1000) {
320
+ return accessToken.token;
321
+ }
322
+ // Since the access token has expired, delete it from the map.
323
+ if (accessToken) {
324
+ this.accessTokenMap.delete(accessTokenKey);
325
+ }
326
+ /**
327
+ * Need to fetch a new access token using refresh token.
328
+ */
329
+ return this.getAccessTokenByRefreshToken(resource, organizationId);
330
+ }
331
+ async #getOrganizationToken(organizationId) {
332
+ if (!this.logtoConfig.scopes?.includes(js.UserScope.Organizations)) {
333
+ throw new errors.LogtoClientError('missing_scope_organizations');
334
+ }
335
+ return this.getAccessToken(undefined, organizationId);
336
+ }
337
+ async #handleSignInCallback(callbackUri) {
338
+ const signInSession = await this.getSignInSession();
339
+ if (!signInSession) {
340
+ throw new errors.LogtoClientError('sign_in_session.not_found');
341
+ }
342
+ const { redirectUri, postRedirectUri, state, codeVerifier } = signInSession;
343
+ const code = js.verifyAndParseCodeFromCallbackUri(callbackUri, redirectUri, state);
344
+ // NOTE: Will add scope to accessTokenKey when needed. (Linear issue LOG-1589)
345
+ const accessTokenKey = index$2.buildAccessTokenKey();
346
+ const { appId: clientId } = this.logtoConfig;
347
+ const { tokenEndpoint } = await this.getOidcConfig();
348
+ const requestedAt = Math.round(Date.now() / 1000);
349
+ const { idToken, refreshToken, accessToken, scope, expiresIn } = await js.fetchTokenByAuthorizationCode({
350
+ clientId,
351
+ tokenEndpoint,
352
+ redirectUri,
353
+ codeVerifier,
354
+ code,
355
+ }, this.adapter.requester);
356
+ await this.jwtVerifier.verifyIdToken(idToken);
357
+ await this.setRefreshToken(refreshToken ?? null);
358
+ await this.setIdToken(idToken);
359
+ this.accessTokenMap.set(accessTokenKey, {
360
+ token: accessToken,
361
+ scope,
362
+ /** The `expiresAt` variable provides an approximate estimation of the actual `exp` property
363
+ * in the token claims. It is utilized by the client to determine if the cached access token
364
+ * has expired and when a new access token should be requested.
365
+ */
366
+ expiresAt: requestedAt + expiresIn,
367
+ });
368
+ await this.saveAccessTokenMap();
369
+ await this.setSignInSession(null);
370
+ if (postRedirectUri) {
371
+ await this.adapter.navigate(postRedirectUri, { for: 'post-sign-in' });
372
+ }
373
+ }
374
+ }
375
+ /* eslint-enable max-lines */
376
+
377
+ exports.StandardLogtoClient = StandardLogtoClient;