@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.
- package/CHANGELOG.md +24 -0
- package/admin/tabs/monitor/MonitorTab.ts +6 -0
- package/admin/tabs/monitor/MonitorTabModel.ts +14 -1
- package/appcontainer/AppContainerModel.ts +2 -2
- package/build/types/admin/tabs/monitor/MonitorTabModel.d.ts +4 -0
- package/build/types/core/types/Types.d.ts +1 -0
- package/build/types/security/BaseOAuthClient.d.ts +72 -48
- package/build/types/security/Token.d.ts +12 -0
- package/build/types/security/authzero/AuthZeroClient.d.ts +24 -10
- package/build/types/security/authzero/index.d.ts +1 -0
- package/build/types/security/msal/MsalClient.d.ts +69 -12
- package/build/types/security/msal/index.d.ts +1 -0
- package/build/types/svc/FetchService.d.ts +5 -5
- package/core/types/Types.ts +1 -0
- package/kit/blueprint/styles.scss +31 -18
- package/package.json +1 -1
- package/security/BaseOAuthClient.ts +177 -165
- package/security/Token.ts +48 -0
- package/security/authzero/AuthZeroClient.ts +109 -88
- package/security/authzero/index.ts +7 -0
- package/security/msal/MsalClient.ts +213 -98
- package/security/msal/index.ts +7 -0
- package/svc/FetchService.ts +14 -11
- package/svc/IdentityService.ts +1 -1
- package/svc/LocalStorageService.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/build/types/security/TokenInfo.d.ts +0 -9
- package/security/TokenInfo.ts +0 -42
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
68
|
+
this.logDebug('Failed to load tokens on init, fall back to login', e.message ?? e);
|
|
40
69
|
}
|
|
41
70
|
}
|
|
42
71
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
52
124
|
}
|
|
53
125
|
|
|
54
|
-
override async
|
|
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
|
|
64
|
-
spec:
|
|
135
|
+
protected override async fetchAccessTokenAsync(
|
|
136
|
+
spec: AuthZeroTokenSpec,
|
|
65
137
|
useCache: boolean = true
|
|
66
|
-
): Promise<
|
|
67
|
-
|
|
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
|
-
//
|
|
88
|
-
|
|
159
|
+
//------------------------
|
|
160
|
+
// Private implementation
|
|
161
|
+
//------------------------
|
|
89
162
|
private createClient(): Auth0Client {
|
|
90
163
|
const config = this.config,
|
|
91
|
-
{clientId, domain
|
|
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
|
|
113
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
174
|
-
|
|
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
|
}
|
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
|
100
|
+
private account: AccountInfo; // Authenticated account
|
|
101
|
+
private initialTokenLoad: boolean;
|
|
43
102
|
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
+
this.initialTokenLoad = true;
|
|
137
|
+
this.logDebug('Attempting silent token load.');
|
|
138
|
+
return await this.fetchAllTokensAsync();
|
|
52
139
|
} catch (e) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
this.
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
//
|
|
66
|
-
|
|
166
|
+
// 3) Return tokens
|
|
167
|
+
return this.fetchAllTokensAsync();
|
|
67
168
|
}
|
|
68
169
|
|
|
69
|
-
override async
|
|
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
|
-
|
|
210
|
+
scopes: this.idScopes,
|
|
211
|
+
forceRefresh: !useCache,
|
|
212
|
+
prompt: 'none',
|
|
213
|
+
...this.refreshOffsetArgs
|
|
74
214
|
});
|
|
75
|
-
|
|
76
|
-
return ret.idToken;
|
|
215
|
+
return new Token(ret.idToken);
|
|
77
216
|
}
|
|
78
217
|
|
|
79
|
-
override async
|
|
80
|
-
spec:
|
|
218
|
+
protected override async fetchAccessTokenAsync(
|
|
219
|
+
spec: MsalTokenSpec,
|
|
81
220
|
useCache: boolean = true
|
|
82
|
-
): Promise<
|
|
221
|
+
): Promise<Token> {
|
|
83
222
|
const ret = await this.client.acquireTokenSilent({
|
|
84
|
-
scopes: spec.scopes,
|
|
85
223
|
account: this.account,
|
|
86
|
-
|
|
224
|
+
scopes: spec.scopes,
|
|
225
|
+
forceRefresh: !useCache,
|
|
226
|
+
prompt: 'none',
|
|
227
|
+
...this.refreshOffsetArgs
|
|
87
228
|
});
|
|
88
|
-
|
|
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,
|
|
232
|
+
protected override async doLogoutAsync(): Promise<void> {
|
|
233
|
+
const {postLogoutRedirectUrl, client, account, loginMethod} = this;
|
|
94
234
|
await client.clearCache({account});
|
|
95
|
-
|
|
235
|
+
loginMethod == 'REDIRECT'
|
|
96
236
|
? await client.logoutRedirect({account, postLogoutRedirectUri: postLogoutRedirectUrl})
|
|
97
237
|
: await client.logoutPopup({account});
|
|
98
238
|
}
|
|
99
239
|
|
|
100
240
|
//------------------------
|
|
101
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
200
|
-
return uniq(
|
|
201
|
-
|
|
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
|
}
|