@xh/hoist 64.0.0 → 64.0.3

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.
@@ -5,18 +5,29 @@
5
5
  * Copyright © 2024 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {Auth0Client} from '@auth0/auth0-spa-js';
8
- import {PlainObject, XH} from '@xh/hoist/core';
8
+ import {XH} from '@xh/hoist/core';
9
9
  import {never, wait} from '@xh/hoist/promise';
10
+ import {Token, TokenMap} from '@xh/hoist/security/Token';
10
11
  import {SECONDS} from '@xh/hoist/utils/datetime';
11
12
  import {throwIf} from '@xh/hoist/utils/js';
12
13
  import {flatMap, union} from 'lodash';
13
14
  import {BaseOAuthClient, BaseOAuthClientConfig} from '../BaseOAuthClient';
14
15
 
15
- export interface AuthZeroClientConfig extends BaseOAuthClientConfig {
16
+ export interface AuthZeroClientConfig extends BaseOAuthClientConfig<AuthZeroTokenSpec> {
16
17
  /** Domain of your app registered with Auth0 */
17
18
  domain: string;
19
+ }
20
+
21
+ export interface AuthZeroTokenSpec {
22
+ /** Scopes for the desired access token.*/
23
+ scopes: string[];
18
24
 
19
- /** Audience (i.e. API) identifier for AccessToken. Must be registered with Auth0*/
25
+ /**
26
+ * Audience (i.e. API) identifier for AccessToken. Must be registered with Auth0.
27
+ *
28
+ * Note that this is required to ensure that issued token is a JWT and not
29
+ * an opaque string.
30
+ */
20
31
  audience: string;
21
32
  }
22
33
 
@@ -25,52 +36,114 @@ export interface AuthZeroClientConfig extends BaseOAuthClientConfig {
25
36
  * via Google, GitHub, Microsoft, and various other OAuth providers *or* via a username/password
26
37
  * combo stored and managed within Auth0's own database. Supported options will depend on the
27
38
  * configuration of your Auth0 app.
39
+ *
40
+ * Note: If developing on localhost and using Access Tokens will need to configure your browser to
41
+ * allow third-party cookies.
28
42
  */
29
- export class AuthZeroClient extends BaseOAuthClient<AuthZeroClientConfig> {
43
+ export class AuthZeroClient extends BaseOAuthClient<AuthZeroClientConfig, AuthZeroTokenSpec> {
30
44
  private client: Auth0Client;
31
45
 
32
- override async doInitAsync(): Promise<void> {
46
+ //-------------------------------------------
47
+ // Implementations of core lifecycle methods
48
+ //-------------------------------------------
49
+ protected override async doInitAsync(): Promise<TokenMap> {
33
50
  const client = (this.client = this.createClient());
34
51
 
52
+ // 0) Returning - call client to complete redirect, and restore state
53
+ if (this.returningFromRedirect()) {
54
+ this.logDebug('Completing Redirect login');
55
+ const {appState} = await client.handleRedirectCallback();
56
+ this.restoreRedirectState(appState);
57
+ await this.noteUserAuthenticatedAsync();
58
+ return this.fetchAllTokensAsync();
59
+ }
60
+
61
+ // 1) If we are logged in, try to just reload tokens silently. This is the happy path on
62
+ // recent refresh.
35
63
  if (await client.isAuthenticated()) {
36
64
  try {
37
- return await this.loadTokensAsync();
65
+ this.logDebug('Attempting silent token load.');
66
+ return await this.fetchAllTokensAsync();
38
67
  } catch (e) {
39
- this.logDebug('Failed to load tokens on init, falling back on login', e);
68
+ this.logDebug('Failed to load tokens on init, fall back to login', e.message ?? e);
40
69
  }
41
70
  }
42
71
 
43
- this.usesRedirect
44
- ? await this.completeViaRedirectAsync()
45
- : await this.completeViaPopupAsync();
72
+ // 2) otherwise full-login
73
+ this.logDebug('Logging in');
74
+ await this.loginAsync();
75
+
76
+ // 3) return tokens
77
+ return this.fetchAllTokensAsync();
78
+ }
79
+
80
+ protected override async doLoginRedirectAsync(): Promise<void> {
81
+ const appState = this.captureRedirectState();
82
+ await this.client.loginWithRedirect({
83
+ appState,
84
+ authorizationParams: {scope: this.loginScope}
85
+ });
86
+ await never();
87
+ }
88
+
89
+ protected override async doLoginPopupAsync(): Promise<void> {
90
+ try {
91
+ await this.client.loginWithPopup({authorizationParams: {scope: this.loginScope}});
92
+ await this.noteUserAuthenticatedAsync();
93
+ } catch (e) {
94
+ const msg = e.message?.toLowerCase();
95
+ e.popup?.close();
96
+ if (msg === 'timeout') {
97
+ throw XH.exception({
98
+ name: 'Auth0 Login Error',
99
+ message:
100
+ 'Login popup window timed out. Please reload this tab in your browser to try again.',
101
+ cause: e
102
+ });
103
+ }
104
+
105
+ if (msg === 'popup closed') {
106
+ throw XH.exception({
107
+ name: 'Auth0 Login Error',
108
+ message:
109
+ 'Login popup window closed. Please reload this tab in your browser to try again.',
110
+ cause: e
111
+ });
112
+ }
46
113
 
47
- const user = await client.getUser();
48
- this.logDebug(`(Re)authenticated OK via Auth0`, user.email, user);
114
+ if (msg.includes('unable to open a popup')) {
115
+ throw XH.exception({
116
+ name: 'Auth0 Login Error',
117
+ message: this.popupBlockerErrorMessage,
118
+ cause: e
119
+ });
120
+ }
49
121
 
50
- // Second-time (after login) the charm!
51
- await this.loadTokensAsync();
122
+ throw e;
123
+ }
52
124
  }
53
125
 
54
- override async getIdTokenAsync(useCache: boolean = true): Promise<string> {
126
+ protected override async fetchIdTokenAsync(useCache: boolean = true): Promise<Token> {
55
127
  const response = await this.client.getTokenSilently({
56
128
  authorizationParams: {scope: this.idScopes.join(' ')},
57
129
  cacheMode: useCache ? 'on' : 'off',
58
130
  detailedResponse: true
59
131
  });
60
- return response.id_token;
132
+ return new Token(response.id_token);
61
133
  }
62
134
 
63
- override async getAccessTokenAsync(
64
- spec: PlainObject,
135
+ protected override async fetchAccessTokenAsync(
136
+ spec: AuthZeroTokenSpec,
65
137
  useCache: boolean = true
66
- ): Promise<string> {
67
- return this.client.getTokenSilently({
68
- authorizationParams: {scope: spec.scopes.join(' ')},
138
+ ): Promise<Token> {
139
+ const value = await this.client.getTokenSilently({
140
+ authorizationParams: {scope: spec.scopes.join(' '), audience: spec.audience},
69
141
  cacheMode: useCache ? 'on' : 'off'
70
142
  });
143
+ return new Token(value);
71
144
  }
72
145
 
73
- override async doLogoutAsync(): Promise<void> {
146
+ protected override async doLogoutAsync(): Promise<void> {
74
147
  const {client} = this;
75
148
  if (!(await client.isAuthenticated())) return;
76
149
  await client.logout({
@@ -83,15 +156,14 @@ export class AuthZeroClient extends BaseOAuthClient<AuthZeroClientConfig> {
83
156
  await wait(10 * SECONDS);
84
157
  }
85
158
 
86
- //------------------
87
- // Implementation
88
- //-----------------
159
+ //------------------------
160
+ // Private implementation
161
+ //------------------------
89
162
  private createClient(): Auth0Client {
90
163
  const config = this.config,
91
- {clientId, domain, audience} = config;
164
+ {clientId, domain} = config;
92
165
 
93
166
  throwIf(!domain, 'Missing Auth0 "domain". Please review your config.');
94
- throwIf(!audience, 'Missing Auth0 "audience" for Access Token. Please review your config.');
95
167
 
96
168
  const ret = new Auth0Client({
97
169
  clientId,
@@ -99,78 +171,27 @@ export class AuthZeroClient extends BaseOAuthClient<AuthZeroClientConfig> {
99
171
  useRefreshTokens: true,
100
172
  useRefreshTokensFallback: true,
101
173
  authorizationParams: {
102
- audience,
103
174
  scope: this.loginScope,
104
175
  redirect_uri: this.redirectUrl
105
176
  },
106
177
  cacheLocation: 'localstorage'
107
178
  });
108
- this.logDebug('Auth0 client created', ret);
109
179
  return ret;
110
180
  }
111
181
 
112
- private async completeViaRedirectAsync(): Promise<void> {
113
- const {client} = this;
114
-
115
- // Determine if we are on back end of redirect (recipe from Auth0 docs)
116
- const {search} = window.location,
117
- isReturning =
118
- (search.includes('state=') && search.includes('code=')) ||
119
- search.includes('error=');
120
-
121
- if (!isReturning) {
122
- // 1) Initiating - grab state and initiate redirect
123
- const appState = this.captureRedirectState();
124
- await client.loginWithRedirect({
125
- appState,
126
- authorizationParams: {scope: this.loginScope}
127
- });
128
- await never();
129
- } else {
130
- // 2) Returning - call client to complete redirect, and restore state
131
- const {appState} = await client.handleRedirectCallback();
132
- this.restoreRedirectState(appState);
133
- }
182
+ private get loginScope(): string {
183
+ return union(this.idScopes, flatMap(this.config.accessTokens, 'scopes')).join(' ');
134
184
  }
135
185
 
136
- private async completeViaPopupAsync(): Promise<void> {
137
- const {client} = this;
138
- try {
139
- await client.loginWithPopup({authorizationParams: {scope: this.loginScope}});
140
- } catch (e) {
141
- const msg = e.message?.toLowerCase();
142
- e.popup?.close();
143
- if (msg === 'timeout') {
144
- throw XH.exception({
145
- name: 'Auth0 Login Error',
146
- message:
147
- 'Login popup window timed out. Please reload this tab in your browser to try again.',
148
- cause: e
149
- });
150
- }
151
-
152
- if (msg === 'popup closed') {
153
- throw XH.exception({
154
- name: 'Auth0 Login Error',
155
- message:
156
- 'Login popup window closed. Please reload this tab in your browser to try again.',
157
- cause: e
158
- });
159
- }
160
-
161
- if (msg.includes('unable to open a popup')) {
162
- throw XH.exception({
163
- name: 'Auth0 Login Error',
164
- message: this.popupBlockerErrorMessage,
165
- cause: e
166
- });
167
- }
168
-
169
- throw e;
170
- }
186
+ private returningFromRedirect(): boolean {
187
+ // Determine if we are on back end of redirect (recipe from Auth0 docs)
188
+ const {search} = window.location;
189
+ return (search.includes('state=') && search.includes('code=')) || search.includes('error=');
171
190
  }
172
191
 
173
- private get loginScope(): string {
174
- return union(this.idScopes, flatMap(this.config.accessTokens, 'scopes')).join(' ');
192
+ private async noteUserAuthenticatedAsync() {
193
+ const user = await this.client.getUser();
194
+ this.setSelectedUsername(user.email);
195
+ this.logDebug('User Authenticated', user.email);
175
196
  }
176
197
  }
@@ -0,0 +1,7 @@
1
+ /*
2
+ * This file belongs to Hoist, an application development toolkit
3
+ * developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
4
+ *
5
+ * Copyright © 2024 Extremely Heavy Industries Inc.
6
+ */
7
+ export * from './AuthZeroClient';
@@ -7,19 +7,20 @@
7
7
  import * as msal from '@azure/msal-browser';
8
8
  import {
9
9
  AccountInfo,
10
- InteractionRequiredAuthError,
11
10
  IPublicClientApplication,
11
+ LogLevel,
12
12
  PopupRequest,
13
- RedirectRequest
13
+ RedirectRequest,
14
+ SilentRequest
14
15
  } from '@azure/msal-browser';
15
- import {LogLevel} from '@azure/msal-common';
16
- import {PlainObject, XH} from '@xh/hoist/core';
16
+ import {XH} from '@xh/hoist/core';
17
17
  import {never} from '@xh/hoist/promise';
18
+ import {Token, TokenMap} from '@xh/hoist/security/Token';
18
19
  import {logDebug, logError, logInfo, logWarn, throwIf} from '@xh/hoist/utils/js';
19
20
  import {flatMap, union, uniq} from 'lodash';
20
21
  import {BaseOAuthClient, BaseOAuthClientConfig} from '../BaseOAuthClient';
21
22
 
22
- export interface MsalClientConfig extends BaseOAuthClientConfig {
23
+ export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
23
24
  /** Tenant ID (GUID) of your organization */
24
25
  tenantId: string;
25
26
 
@@ -30,83 +31,234 @@ export interface MsalClientConfig extends BaseOAuthClientConfig {
30
31
  */
31
32
  authority?: string;
32
33
 
33
- /** The log level of MSAL. Default is LogLevel.Info (2). */
34
+ /**
35
+ * A hint about the tenant or domain that the user should use to sign in.
36
+ * The value of the domain hint is a registered domain for the tenant.
37
+ */
38
+ domainHint?: string;
39
+
40
+ /**
41
+ * If specified, the client will use this value when initializing the app to enforce a minimum
42
+ * amount of time during which no further auth flow with the provider should be necessary.
43
+ *
44
+ * Use this argument to front-load any necessary auth flow to the apps initialization stage
45
+ * thereby minimizing disruption to user activity during application use.
46
+ *
47
+ * This value may be set to anything up to 86400 (24 hours), the maximum lifetime
48
+ * of an Azure refresh token. Set to -1 to disable (default).
49
+ *
50
+ * Note that setting to *any* non-disabled amount will require the app to do *some* communication
51
+ * with the login provider at *every* app load. This may just involve loading new tokens via
52
+ * fetch, however, setting to higher values will increase the frequency with which
53
+ * a new refresh token will also need to be requested via a hidden iframe/redirect/popup. This
54
+ * can be time-consuming and potentially disruptive and applications should therefore use with
55
+ * care and typically set to some value significantly less than the max.
56
+ *
57
+ * See https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/token-lifetimes.md
58
+ */
59
+ initRefreshTokenExpirationOffsetSecs?: number;
60
+
61
+ /** The log level of MSAL. Default is LogLevel.Warning. */
34
62
  msalLogLevel?: LogLevel;
35
63
  }
36
64
 
65
+ export interface MsalTokenSpec {
66
+ /** Scopes for the desired access token. */
67
+ scopes: string[];
68
+
69
+ /**
70
+ * Scopes to be added to the scopes requested during interactive and SSO logins.
71
+ * See the `scopes` property on `PopupRequest`, `RedirectRequest`, and `SSORequest`
72
+ * for more info.
73
+ */
74
+ loginScopes?: string[];
75
+
76
+ /**
77
+ * Scopes to be added to the scopes requested during interactive and SSO login.
78
+ *
79
+ * See the `extraScopesToConsent` property on `PopupRequest`, `RedirectRequest`, and
80
+ * `SSORequest` for more info.
81
+ */
82
+ extraScopesToConsent?: string[];
83
+ }
84
+
37
85
  /**
38
86
  * Service to implement OAuth authentication via MSAL.
87
+ *
88
+ * See the following helpful information relevant to our use of this tricky API --
89
+ * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/
90
+ * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/token-lifetimes.md
91
+ * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/login-user.md
92
+ *
93
+ * TODO: The handling of `ssoSilent` and `initRefreshTokenExpirationOffsetSecs` in this library
94
+ * require 3rd party cookies to be enabled in the browser so that MSAL can load contact in a
95
+ * hidden iFrame If its *not* enabled, we may be doing extra work. Consider checking 3rd party
96
+ * cookie support and adding conditional behavior?
39
97
  */
40
- export class MsalClient extends BaseOAuthClient<MsalClientConfig> {
98
+ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec> {
41
99
  private client: IPublicClientApplication;
42
- private account: AccountInfo; // Authenticated account, as most recent auth call with Azure.
100
+ private account: AccountInfo; // Authenticated account
101
+ private initialTokenLoad: boolean;
43
102
 
44
- override async doInitAsync(): Promise<void> {
103
+ constructor(config: MsalClientConfig) {
104
+ super({
105
+ initRefreshTokenExpirationOffsetSecs: -1,
106
+ msalLogLevel: LogLevel.Warning,
107
+ domainHint: null,
108
+ ...config
109
+ });
110
+ }
111
+
112
+ //-------------------------------------------
113
+ // Implementations of core lifecycle methods
114
+ //-------------------------------------------
115
+ protected override async doInitAsync(): Promise<TokenMap> {
45
116
  const client = (this.client = await this.createClientAsync());
46
117
 
47
- this.account = client.getAllAccounts()[0];
118
+ // 0) Handle redirect return
119
+ const redirectResp = await client.handleRedirectPromise();
120
+ if (redirectResp) {
121
+ this.logDebug('Completing Redirect login');
122
+ this.noteUserAuthenticated(redirectResp.account);
123
+ this.restoreRedirectState(redirectResp.state);
124
+ return this.fetchAllTokensAsync();
125
+ }
48
126
 
127
+ // 1) If we are logged in, try to just reload tokens silently. This is the happy path on
128
+ // recent refresh. This should never trigger popup/redirect, but if
129
+ // 'initRefreshTokenExpirationOffsetSecs' is set, this may trigger a hidden iframe redirect
130
+ // to gain a new refreshToken (3rd party cookies required).
131
+ const accounts = client.getAllAccounts();
132
+ this.logDebug('Authenticated accounts available', accounts);
133
+ this.account = accounts.length == 1 ? accounts[0] : null;
49
134
  if (this.account) {
50
135
  try {
51
- return await this.loadTokensAsync();
136
+ this.initialTokenLoad = true;
137
+ this.logDebug('Attempting silent token load.');
138
+ return await this.fetchAllTokensAsync();
52
139
  } catch (e) {
53
- if (!(e instanceof InteractionRequiredAuthError)) {
54
- throw e;
55
- }
56
- this.logDebug('Failed to load tokens on init, falling back on login', e);
140
+ this.account = null;
141
+ this.logDebug('Failed to load tokens on init, fall back to login', e.message ?? e);
142
+ } finally {
143
+ this.initialTokenLoad = false;
57
144
  }
58
145
  }
59
146
 
60
- this.usesRedirect
61
- ? await this.completeViaRedirectAsync()
62
- : await this.completeViaPopupAsync();
63
- this.logDebug(`(Re)authenticated OK via Azure`, this.account.username, this.account);
147
+ // 2) Otherwise need to login.
148
+ // 2a) Try MSALs `ssoSilent` API, to potentially reuse logged-in user on other apps in same
149
+ // domain without interaction. This should never trigger popup/redirect, but will use an iFrame
150
+ // if available (3rd party cookies required). Will work if MSAL can resolve a single
151
+ // logged-in user with access to app and meeting all hint criteria.
152
+ try {
153
+ this.logDebug('Attempting SSO');
154
+ await this.loginSsoAsync();
155
+ } catch (e) {
156
+ this.logDebug('SSO failed', e.message ?? e);
157
+ }
158
+
159
+ // 2b) Otherwise do full interactive login. This may or may not require user involvement
160
+ // but will require at the very least a redirect or cursory auto-closing popup.
161
+ if (!this.account) {
162
+ this.logDebug('Logging in');
163
+ await this.loginAsync();
164
+ }
64
165
 
65
- // Second-time (after login) the charm!
66
- await this.loadTokensAsync();
166
+ // 3) Return tokens
167
+ return this.fetchAllTokensAsync();
67
168
  }
68
169
 
69
- override async getIdTokenAsync(useCache: boolean = true): Promise<string> {
170
+ protected override async doLoginPopupAsync(): Promise<void> {
171
+ const {client} = this,
172
+ opts: PopupRequest = {
173
+ loginHint: this.getSelectedUsername(),
174
+ domainHint: this.config.domainHint,
175
+ scopes: this.loginScopes,
176
+ extraScopesToConsent: this.loginExtraScopesToConsent
177
+ };
178
+ try {
179
+ const ret = await client.acquireTokenPopup(opts);
180
+ this.noteUserAuthenticated(ret.account);
181
+ } catch (e) {
182
+ if (e.message?.toLowerCase().includes('popup window')) {
183
+ throw XH.exception({
184
+ name: 'Azure Login Error',
185
+ message: this.popupBlockerErrorMessage,
186
+ cause: e
187
+ });
188
+ }
189
+ throw e;
190
+ }
191
+ }
192
+
193
+ protected override async doLoginRedirectAsync(): Promise<void> {
194
+ const {client} = this,
195
+ state = this.captureRedirectState(),
196
+ opts: RedirectRequest = {
197
+ state,
198
+ loginHint: this.getSelectedUsername(),
199
+ domainHint: this.config.domainHint,
200
+ scopes: this.loginScopes,
201
+ extraScopesToConsent: this.loginExtraScopesToConsent
202
+ };
203
+ await client.acquireTokenRedirect(opts);
204
+ await never();
205
+ }
206
+
207
+ protected override async fetchIdTokenAsync(useCache: boolean = true): Promise<Token> {
70
208
  const ret = await this.client.acquireTokenSilent({
71
- scopes: this.idScopes,
72
209
  account: this.account,
73
- forceRefresh: !useCache
210
+ scopes: this.idScopes,
211
+ forceRefresh: !useCache,
212
+ prompt: 'none',
213
+ ...this.refreshOffsetArgs
74
214
  });
75
- this.account = ret.account;
76
- return ret.idToken;
215
+ return new Token(ret.idToken);
77
216
  }
78
217
 
79
- override async getAccessTokenAsync(
80
- spec: PlainObject,
218
+ protected override async fetchAccessTokenAsync(
219
+ spec: MsalTokenSpec,
81
220
  useCache: boolean = true
82
- ): Promise<string> {
221
+ ): Promise<Token> {
83
222
  const ret = await this.client.acquireTokenSilent({
84
- scopes: spec.scopes,
85
223
  account: this.account,
86
- forceRefresh: !useCache
224
+ scopes: spec.scopes,
225
+ forceRefresh: !useCache,
226
+ prompt: 'none',
227
+ ...this.refreshOffsetArgs
87
228
  });
88
- this.account = ret.account;
89
- return ret.accessToken;
229
+ return new Token(ret.accessToken);
90
230
  }
91
231
 
92
- override async doLogoutAsync(): Promise<void> {
93
- const {postLogoutRedirectUrl, client, account, usesRedirect} = this;
232
+ protected override async doLogoutAsync(): Promise<void> {
233
+ const {postLogoutRedirectUrl, client, account, loginMethod} = this;
94
234
  await client.clearCache({account});
95
- usesRedirect
235
+ loginMethod == 'REDIRECT'
96
236
  ? await client.logoutRedirect({account, postLogoutRedirectUri: postLogoutRedirectUrl})
97
237
  : await client.logoutPopup({account});
98
238
  }
99
239
 
100
240
  //------------------------
101
- // Implementation
241
+ // Private implementation
102
242
  //------------------------
243
+ private async loginSsoAsync(): Promise<void> {
244
+ const result = await this.client.ssoSilent({
245
+ loginHint: this.getSelectedUsername(),
246
+ domainHint: this.config.domainHint,
247
+ redirectUri: this.redirectUrl, // Recommended by MS, not used?
248
+ scopes: this.loginScopes,
249
+ extraScopesToConsent: this.loginExtraScopesToConsent,
250
+ prompt: 'none'
251
+ });
252
+ this.noteUserAuthenticated(result.account);
253
+ }
254
+
103
255
  private async createClientAsync(): Promise<IPublicClientApplication> {
104
256
  const config = this.config,
105
257
  {clientId, authority, msalLogLevel} = config;
106
258
 
107
259
  throwIf(!authority, 'Missing MSAL authority. Please review your configuration.');
108
260
 
109
- const ret = await msal.PublicClientApplication.createPublicClientApplication({
261
+ return msal.PublicClientApplication.createPublicClientApplication({
110
262
  auth: {
111
263
  clientId,
112
264
  authority,
@@ -116,61 +268,13 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig> {
116
268
  system: {
117
269
  loggerOptions: {
118
270
  loggerCallback: this.logFromMsal,
119
- logLevel: msalLogLevel ?? 1
271
+ logLevel: msalLogLevel
120
272
  }
273
+ },
274
+ cache: {
275
+ cacheLocation: 'localStorage' // allows sharing auth info across tabs.
121
276
  }
122
277
  });
123
- this.logDebug('MSAL client created', ret);
124
- return ret;
125
- }
126
-
127
- private async completeViaRedirectAsync(): Promise<void> {
128
- const {client, account} = this,
129
- redirectResp = await client.handleRedirectPromise();
130
-
131
- if (!redirectResp) {
132
- // 1) Initiating - grab state and initiate redirect
133
- const state = this.captureRedirectState(),
134
- opts: RedirectRequest = {
135
- state,
136
- scopes: this.loginScopes,
137
- extraScopesToConsent: this.loginExtraScopes
138
- };
139
- account
140
- ? await client.acquireTokenRedirect({...opts, account})
141
- : await client.loginRedirect(opts);
142
-
143
- await never();
144
- } else {
145
- // 2) Returning - just restore state
146
- this.account = redirectResp.account;
147
- const redirectState = redirectResp.state;
148
- this.restoreRedirectState(redirectState);
149
- }
150
- }
151
-
152
- private async completeViaPopupAsync(): Promise<void> {
153
- const {client, account} = this,
154
- opts: PopupRequest = {
155
- scopes: this.loginScopes,
156
- extraScopesToConsent: this.loginExtraScopes
157
- };
158
- try {
159
- const ret = account
160
- ? await client.acquireTokenPopup({...opts, account})
161
- : await client.loginPopup(opts);
162
-
163
- this.account = ret.account;
164
- } catch (e) {
165
- if (e.message?.toLowerCase().includes('popup window')) {
166
- throw XH.exception({
167
- name: 'Azure Login Error',
168
- message: this.popupBlockerErrorMessage,
169
- cause: e
170
- });
171
- }
172
- throw e;
173
- }
174
278
  }
175
279
 
176
280
  private logFromMsal(level: LogLevel, message: string) {
@@ -188,17 +292,28 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig> {
188
292
  }
189
293
 
190
294
  private get loginScopes(): string[] {
191
- return union(
192
- this.idScopes,
193
- flatMap(this.config.accessTokens, spec =>
194
- spec.scopes.filter(s => !s.startsWith('api:'))
295
+ return uniq(
296
+ union(
297
+ this.idScopes,
298
+ flatMap(this.config.accessTokens, spec => spec.loginScopes ?? [])
195
299
  )
196
300
  );
197
301
  }
198
302
 
199
- private get loginExtraScopes(): string[] {
200
- return uniq(
201
- flatMap(this.config.accessTokens, spec => spec.scopes.filter(s => s.startsWith('api:')))
202
- );
303
+ private get loginExtraScopesToConsent(): string[] {
304
+ return uniq(flatMap(this.config.accessTokens, spec => spec.extraScopesToConsent ?? []));
305
+ }
306
+
307
+ private get refreshOffsetArgs(): Partial<SilentRequest> {
308
+ const offset = this.config.initRefreshTokenExpirationOffsetSecs;
309
+ return offset > 0 && this.initialTokenLoad
310
+ ? {forceRefresh: true, refreshTokenExpirationOffsetSeconds: offset}
311
+ : {};
312
+ }
313
+
314
+ private noteUserAuthenticated(account: AccountInfo) {
315
+ this.account = account;
316
+ this.setSelectedUsername(account.username);
317
+ this.logDebug('User Authenticated', account.username);
203
318
  }
204
319
  }