@jbrowse/plugin-authentication 4.3.0 → 5.0.0-beta.2
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/esm/DropboxOAuthModel/DropboxIcon.d.ts +1 -1
- package/esm/DropboxOAuthModel/configSchema.d.ts +110 -82
- package/esm/DropboxOAuthModel/configSchema.js +32 -2
- package/esm/DropboxOAuthModel/index.js +2 -2
- package/esm/DropboxOAuthModel/model.d.ts +235 -201
- package/esm/DropboxOAuthModel/model.js +46 -21
- package/esm/DropboxOAuthModel/util.d.ts +1 -1
- package/esm/DropboxOAuthModel/util.js +9 -12
- package/esm/ExternalTokenModel/ExternalTokenEntryForm.d.ts +1 -1
- package/esm/ExternalTokenModel/ExternalTokenEntryForm.js +11 -15
- package/esm/ExternalTokenModel/configSchema.d.ts +43 -24
- package/esm/ExternalTokenModel/configSchema.js +22 -1
- package/esm/ExternalTokenModel/index.js +2 -2
- package/esm/ExternalTokenModel/model.d.ts +47 -71
- package/esm/ExternalTokenModel/model.js +11 -54
- package/esm/GoogleDriveOAuthModel/GoogleDriveFilehandle.d.ts +2 -9
- package/esm/GoogleDriveOAuthModel/GoogleDriveFilehandle.js +27 -6
- package/esm/GoogleDriveOAuthModel/GoogleDriveIcon.d.ts +1 -1
- package/esm/GoogleDriveOAuthModel/configSchema.d.ts +109 -82
- package/esm/GoogleDriveOAuthModel/configSchema.js +32 -3
- package/esm/GoogleDriveOAuthModel/index.js +2 -2
- package/esm/GoogleDriveOAuthModel/model.d.ts +230 -202
- package/esm/GoogleDriveOAuthModel/model.js +45 -22
- package/esm/GoogleDriveOAuthModel/util.d.ts +1 -1
- package/esm/GoogleDriveOAuthModel/util.js +2 -11
- package/esm/HTTPBasicModel/HTTPBasicLoginForm.d.ts +1 -1
- package/esm/HTTPBasicModel/HTTPBasicLoginForm.js +17 -22
- package/esm/HTTPBasicModel/configSchema.d.ts +51 -28
- package/esm/HTTPBasicModel/configSchema.js +26 -1
- package/esm/HTTPBasicModel/index.js +2 -2
- package/esm/HTTPBasicModel/model.d.ts +55 -76
- package/esm/HTTPBasicModel/model.js +21 -51
- package/esm/OAuthModel/OAuthLoginPrompt.d.ts +4 -0
- package/esm/OAuthModel/OAuthLoginPrompt.js +12 -0
- package/esm/OAuthModel/configSchema.d.ts +79 -52
- package/esm/OAuthModel/configSchema.js +30 -1
- package/esm/OAuthModel/index.js +2 -2
- package/esm/OAuthModel/model.d.ts +218 -114
- package/esm/OAuthModel/model.js +361 -217
- package/esm/OAuthModel/util.d.ts +46 -7
- package/esm/OAuthModel/util.js +159 -16
- package/esm/index.d.ts +295 -981
- package/esm/index.js +29 -45
- package/esm/lazyLoginForms.d.ts +18 -0
- package/esm/lazyLoginForms.js +25 -0
- package/esm/tokenEntryModelFactory.d.ts +125 -0
- package/esm/tokenEntryModelFactory.js +85 -0
- package/esm/util.d.ts +10 -0
- package/esm/util.js +24 -2
- package/package.json +10 -13
package/esm/OAuthModel/model.js
CHANGED
|
@@ -1,280 +1,424 @@
|
|
|
1
|
-
import { ConfigurationReference,
|
|
1
|
+
import { ConfigurationReference, readConfObject, } from '@jbrowse/core/configuration';
|
|
2
2
|
import { InternetAccount } from '@jbrowse/core/pluggableElementTypes/models';
|
|
3
|
-
import { isElectron } from '@jbrowse/core/util';
|
|
4
|
-
import { types } from '@jbrowse/mobx-state-tree';
|
|
5
|
-
import {
|
|
6
|
-
import { getResponseError } from
|
|
3
|
+
import { isElectron, isSessionModel, localStorageGetItem, localStorageRemoveItem, localStorageSetItem, sha256Base64Url, toBase64Url, } from '@jbrowse/core/util';
|
|
4
|
+
import { getRoot, types } from '@jbrowse/mobx-state-tree';
|
|
5
|
+
import { OAuthLoginPrompt } from '../lazyLoginForms.js';
|
|
6
|
+
import { getResponseError } from '../util.js';
|
|
7
|
+
import { finishOAuthRedirect, finishOAuthWindow, getOAuthRedirectUri, parseOAuthError, waitForOAuthMessage, } from './util.js';
|
|
8
|
+
function randomBytes(length) {
|
|
9
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(length));
|
|
10
|
+
}
|
|
11
|
+
// Whether a popup opened right now would be one the browser lets through.
|
|
12
|
+
// `in`, because the property is typed as always present but Firefox has no
|
|
13
|
+
// implementation — and a browser that cannot answer gets the attempt.
|
|
14
|
+
const hasTransientActivation = () => 'userActivation' in navigator ? navigator.userActivation.isActive : true;
|
|
15
|
+
/**
|
|
16
|
+
* Puts the login window behind a click, for the flow that has no user
|
|
17
|
+
* activation to open one with — a session loading a track of an authenticated
|
|
18
|
+
* resource, which is every share link to one. Rejects if the user dismisses
|
|
19
|
+
* the prompt, and `getToken` drops the cached promise on a rejection so a
|
|
20
|
+
* later read asks again.
|
|
21
|
+
*/
|
|
22
|
+
function promptForLoginWindow({ internetAccountId, session, openLoginWindow, waitForToken, }) {
|
|
23
|
+
const blocked = new Error(`Could not open the ${internetAccountId} login window. Allow popups for this site and try again.`);
|
|
24
|
+
// an embedded root need not have a session to queue the prompt on, and a
|
|
25
|
+
// popup that never opens used to leave this promise pending forever — which
|
|
26
|
+
// `getToken` caches, so the account stayed wedged for the rest of the
|
|
27
|
+
// session with nothing shown to the user
|
|
28
|
+
if (!isSessionModel(session)) {
|
|
29
|
+
throw blocked;
|
|
30
|
+
}
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
session.queueDialog(doneCallback => [
|
|
33
|
+
OAuthLoginPrompt,
|
|
34
|
+
{
|
|
35
|
+
internetAccountId,
|
|
36
|
+
handleClose: (proceed) => {
|
|
37
|
+
// opened before doneCallback, so the open still runs inside the
|
|
38
|
+
// click whose activation it is spending
|
|
39
|
+
const popup = proceed ? openLoginWindow() : null;
|
|
40
|
+
doneCallback();
|
|
41
|
+
if (popup) {
|
|
42
|
+
resolve(waitForToken(popup));
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
reject(proceed ? blocked : new Error('OAuth login prompt was dismissed'));
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* #stateModel OAuthInternetAccount
|
|
54
|
+
*/
|
|
7
55
|
const stateModelFactory = (configSchema) => {
|
|
8
56
|
return InternetAccount.named('OAuthInternetAccount')
|
|
9
57
|
.props({
|
|
58
|
+
/**
|
|
59
|
+
* #property
|
|
60
|
+
*/
|
|
10
61
|
type: types.literal('OAuthInternetAccount'),
|
|
62
|
+
/**
|
|
63
|
+
* #property
|
|
64
|
+
*/
|
|
11
65
|
configuration: ConfigurationReference(configSchema),
|
|
12
66
|
})
|
|
13
|
-
.views(() => {
|
|
14
|
-
let codeVerifier = undefined;
|
|
15
|
-
return {
|
|
16
|
-
get codeVerifierPKCE() {
|
|
17
|
-
if (!codeVerifier) {
|
|
18
|
-
const array = new Uint8Array(32);
|
|
19
|
-
globalThis.crypto.getRandomValues(array);
|
|
20
|
-
codeVerifier = toBase64Url(array);
|
|
21
|
-
}
|
|
22
|
-
return codeVerifier;
|
|
23
|
-
},
|
|
24
|
-
};
|
|
25
|
-
})
|
|
26
67
|
.views(self => ({
|
|
68
|
+
/**
|
|
69
|
+
* #getter
|
|
70
|
+
* The config typed off the concrete schema. `ConfigurationReference`
|
|
71
|
+
* erases `self.configuration` to `any` (the reference's MST instance brand
|
|
72
|
+
* doesn't carry the schema's slot definitions), so reads go through this
|
|
73
|
+
* getter to recover per-slot types and slot-name validation.
|
|
74
|
+
*/
|
|
75
|
+
get conf() {
|
|
76
|
+
return self.configuration;
|
|
77
|
+
},
|
|
78
|
+
}))
|
|
79
|
+
.views(self => ({
|
|
80
|
+
/**
|
|
81
|
+
* #getter
|
|
82
|
+
*/
|
|
27
83
|
get authEndpoint() {
|
|
28
|
-
return
|
|
84
|
+
return readConfObject(self.conf, 'authEndpoint');
|
|
29
85
|
},
|
|
86
|
+
/**
|
|
87
|
+
* #getter
|
|
88
|
+
*/
|
|
30
89
|
get tokenEndpoint() {
|
|
31
|
-
return
|
|
90
|
+
return readConfObject(self.conf, 'tokenEndpoint');
|
|
32
91
|
},
|
|
92
|
+
/**
|
|
93
|
+
* #getter
|
|
94
|
+
*/
|
|
33
95
|
get needsPKCE() {
|
|
34
|
-
return
|
|
96
|
+
return readConfObject(self.conf, 'needsPKCE');
|
|
35
97
|
},
|
|
98
|
+
/**
|
|
99
|
+
* #getter
|
|
100
|
+
*/
|
|
36
101
|
get clientId() {
|
|
37
|
-
return
|
|
102
|
+
return readConfObject(self.conf, 'clientId');
|
|
38
103
|
},
|
|
104
|
+
/**
|
|
105
|
+
* #getter
|
|
106
|
+
*/
|
|
39
107
|
get scopes() {
|
|
40
|
-
return
|
|
108
|
+
return readConfObject(self.conf, 'scopes');
|
|
41
109
|
},
|
|
42
|
-
|
|
43
|
-
|
|
110
|
+
/**
|
|
111
|
+
* #getter
|
|
112
|
+
* OAuth state parameter:
|
|
113
|
+
* https://www.rfc-editor.org/rfc/rfc6749#section-4.1.1
|
|
114
|
+
*
|
|
115
|
+
* Can override or extend if dynamic state is needed.
|
|
116
|
+
*/
|
|
117
|
+
get state() {
|
|
118
|
+
return readConfObject(self.conf, 'state');
|
|
44
119
|
},
|
|
120
|
+
/**
|
|
121
|
+
* #getter
|
|
122
|
+
*/
|
|
45
123
|
get responseType() {
|
|
46
|
-
return
|
|
124
|
+
return readConfObject(self.conf, 'responseType');
|
|
47
125
|
},
|
|
126
|
+
/**
|
|
127
|
+
* #getter
|
|
128
|
+
*/
|
|
48
129
|
get refreshTokenKey() {
|
|
49
130
|
return `${self.internetAccountId}-refreshToken`;
|
|
50
131
|
},
|
|
132
|
+
/**
|
|
133
|
+
* #getter
|
|
134
|
+
* Extra parameters to add to the authorization request. Empty here;
|
|
135
|
+
* a provider that needs one of its own overrides this.
|
|
136
|
+
*/
|
|
137
|
+
get authFlowParams() {
|
|
138
|
+
return {};
|
|
139
|
+
},
|
|
51
140
|
}))
|
|
52
141
|
.actions(self => ({
|
|
142
|
+
/**
|
|
143
|
+
* #action
|
|
144
|
+
*/
|
|
145
|
+
// through the guarded helpers rather than `localStorage` directly: this
|
|
146
|
+
// runs on someone else's page in the embedded products, where reading the
|
|
147
|
+
// global at all throws if third-party storage is blocked. A refresh token
|
|
148
|
+
// that cannot be cached only costs an extra auth prompt; a throw here
|
|
149
|
+
// takes down the whole auth flow.
|
|
53
150
|
storeRefreshToken(refreshToken) {
|
|
54
|
-
|
|
151
|
+
localStorageSetItem(self.refreshTokenKey, refreshToken);
|
|
55
152
|
},
|
|
153
|
+
/**
|
|
154
|
+
* #action
|
|
155
|
+
*/
|
|
56
156
|
removeRefreshToken() {
|
|
57
|
-
|
|
157
|
+
localStorageRemoveItem(self.refreshTokenKey);
|
|
58
158
|
},
|
|
159
|
+
/**
|
|
160
|
+
* #method
|
|
161
|
+
*/
|
|
59
162
|
retrieveRefreshToken() {
|
|
60
|
-
return
|
|
61
|
-
},
|
|
62
|
-
async exchangeAuthorizationForAccessToken(token, redirectUri) {
|
|
63
|
-
const params = new URLSearchParams(Object.entries({
|
|
64
|
-
code: token,
|
|
65
|
-
grant_type: 'authorization_code',
|
|
66
|
-
client_id: self.clientId,
|
|
67
|
-
redirect_uri: redirectUri,
|
|
68
|
-
...(self.needsPKCE ? { code_verifier: self.codeVerifierPKCE } : {}),
|
|
69
|
-
}));
|
|
70
|
-
const response = await fetch(self.tokenEndpoint, {
|
|
71
|
-
method: 'POST',
|
|
72
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
73
|
-
body: params.toString(),
|
|
74
|
-
});
|
|
75
|
-
if (!response.ok) {
|
|
76
|
-
throw new Error(await getResponseError({
|
|
77
|
-
response,
|
|
78
|
-
reason: 'Failed to obtain token',
|
|
79
|
-
}));
|
|
80
|
-
}
|
|
81
|
-
const data = await response.json();
|
|
82
|
-
return processTokenResponse(data, token => {
|
|
83
|
-
this.storeRefreshToken(token);
|
|
84
|
-
});
|
|
163
|
+
return localStorageGetItem(self.refreshTokenKey);
|
|
85
164
|
},
|
|
86
|
-
|
|
165
|
+
}))
|
|
166
|
+
.actions(self => ({
|
|
167
|
+
/**
|
|
168
|
+
* #action
|
|
169
|
+
* POST a grant to the token endpoint and read the access token out of the
|
|
170
|
+
* answer. Both grants this account makes — trading the authorization code
|
|
171
|
+
* on the way in, trading the refresh token when the access token expires
|
|
172
|
+
* — are the same form-encoded request to the same endpoint answered by
|
|
173
|
+
* the same body, and OAuth 2 says so (RFC 6749 §4.1.3, §6). They differ
|
|
174
|
+
* in the grant's own parameters and in what a failure means, which is
|
|
175
|
+
* what stays at the call sites.
|
|
176
|
+
*
|
|
177
|
+
* @param grant - the grant-specific parameters; `client_id` is added
|
|
178
|
+
* @param describeError - turns a failed exchange into a message, and is
|
|
179
|
+
* where a grant does whatever invalidating its failure implies
|
|
180
|
+
*/
|
|
181
|
+
async postTokenGrant(grant, describeError) {
|
|
87
182
|
const response = await fetch(self.tokenEndpoint, {
|
|
88
183
|
method: 'POST',
|
|
89
184
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
90
|
-
body: new URLSearchParams(
|
|
91
|
-
grant_type: 'refresh_token',
|
|
92
|
-
refresh_token: refreshToken,
|
|
185
|
+
body: new URLSearchParams({
|
|
93
186
|
client_id: self.clientId,
|
|
94
|
-
|
|
187
|
+
...grant,
|
|
188
|
+
}).toString(),
|
|
95
189
|
});
|
|
96
190
|
if (!response.ok) {
|
|
97
|
-
|
|
98
|
-
const text = await response.text();
|
|
99
|
-
throw new Error(await getResponseError({
|
|
100
|
-
response,
|
|
101
|
-
statusText: processError(text, () => {
|
|
102
|
-
this.removeRefreshToken();
|
|
103
|
-
}),
|
|
104
|
-
}));
|
|
191
|
+
throw new Error(await describeError(response));
|
|
105
192
|
}
|
|
106
|
-
const data = await response.json();
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
193
|
+
const data = (await response.json());
|
|
194
|
+
// Either grant may rotate the refresh token, and a provider that does
|
|
195
|
+
// so invalidates the old one — so this is not specific to the code
|
|
196
|
+
// exchange even though that is where one first arrives.
|
|
197
|
+
if (data.refresh_token) {
|
|
198
|
+
self.storeRefreshToken(data.refresh_token);
|
|
199
|
+
}
|
|
200
|
+
return data.access_token;
|
|
110
201
|
},
|
|
111
202
|
}))
|
|
112
203
|
.actions(self => {
|
|
113
|
-
|
|
114
|
-
|
|
204
|
+
// Shared across concurrent exchangeRefreshForAccessToken calls so parallel
|
|
205
|
+
// 401s all wait on the same refresh request rather than each triggering a
|
|
206
|
+
// separate one.
|
|
207
|
+
let exchangePromise;
|
|
115
208
|
return {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
209
|
+
/**
|
|
210
|
+
* #action
|
|
211
|
+
*/
|
|
212
|
+
async exchangeAuthorizationForAccessToken(code, redirectUri,
|
|
213
|
+
// required, not optional: an omitted verifier is a silently
|
|
214
|
+
// PKCE-less exchange, and the caller running the flow is the only
|
|
215
|
+
// thing that knows whether this one used PKCE
|
|
216
|
+
codeVerifier) {
|
|
217
|
+
return self.postTokenGrant({
|
|
218
|
+
grant_type: 'authorization_code',
|
|
219
|
+
code,
|
|
220
|
+
redirect_uri: redirectUri,
|
|
221
|
+
...(codeVerifier ? { code_verifier: codeVerifier } : {}),
|
|
222
|
+
}, response => getResponseError({ response, reason: 'Failed to obtain token' }));
|
|
124
223
|
},
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const token = urlParams.get('access_token');
|
|
137
|
-
if (!token) {
|
|
138
|
-
reject(new Error('Error with token endpoint'));
|
|
139
|
-
return;
|
|
224
|
+
/**
|
|
225
|
+
* #action
|
|
226
|
+
*/
|
|
227
|
+
async exchangeRefreshForAccessToken(refreshToken) {
|
|
228
|
+
const inFlight = (exchangePromise ??= self.postTokenGrant({ grant_type: 'refresh_token', refresh_token: refreshToken }, async (response) => {
|
|
229
|
+
// the access token this was going to replace is gone either way,
|
|
230
|
+
// and an invalid_grant means the refresh token itself is spent
|
|
231
|
+
self.removeToken();
|
|
232
|
+
const { isInvalidGrant, statusText } = parseOAuthError(await response.text());
|
|
233
|
+
if (isInvalidGrant) {
|
|
234
|
+
self.removeRefreshToken();
|
|
140
235
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
236
|
+
return getResponseError({ response, statusText });
|
|
237
|
+
}));
|
|
238
|
+
try {
|
|
239
|
+
return await inFlight;
|
|
144
240
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
241
|
+
finally {
|
|
242
|
+
// only if it is still ours: every awaiter of a shared exchange runs
|
|
243
|
+
// this, and a later caller can have started its own between two of
|
|
244
|
+
// them — clearing unconditionally then dropped that one from the
|
|
245
|
+
// slot and let the next 401 open a second refresh in parallel
|
|
246
|
+
if (exchangePromise === inFlight) {
|
|
247
|
+
exchangePromise = undefined;
|
|
150
248
|
}
|
|
151
|
-
try {
|
|
152
|
-
const token = await self.exchangeAuthorizationForAccessToken(code, redirectUrl.origin + redirectUrl.pathname);
|
|
153
|
-
self.storeToken(token);
|
|
154
|
-
resolve(token);
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
catch (e) {
|
|
158
|
-
if (e instanceof Error) {
|
|
159
|
-
reject(e);
|
|
160
|
-
}
|
|
161
|
-
else {
|
|
162
|
-
reject(new Error(String(e)));
|
|
163
|
-
}
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
if (redirectUriWithInfo.includes('access_denied')) {
|
|
168
|
-
reject(new Error('OAuth flow was cancelled'));
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
if (redirectUriWithInfo.includes('error')) {
|
|
172
|
-
reject(new Error(`OAuth flow error: ${queryStringSearch}`));
|
|
173
|
-
return;
|
|
174
249
|
}
|
|
175
|
-
this.deleteMessageChannel();
|
|
176
250
|
},
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
251
|
+
};
|
|
252
|
+
})
|
|
253
|
+
.actions(self => ({
|
|
254
|
+
/**
|
|
255
|
+
* #action
|
|
256
|
+
* Prove a token against the resource and, if that fails, refresh it once
|
|
257
|
+
* and prove the new one. Returns whichever token worked; throws if
|
|
258
|
+
* neither does. Every OAuth account validates this way and they differ
|
|
259
|
+
* only in what a probe is — a HEAD of the resource here, a metadata call
|
|
260
|
+
* for Dropbox and Google Drive.
|
|
261
|
+
*
|
|
262
|
+
* @param token - the token to prove
|
|
263
|
+
* @param probe - issues a request that succeeds iff the token is good
|
|
264
|
+
* @param describeError - turns a failed probe into a message
|
|
265
|
+
*/
|
|
266
|
+
async validateTokenWithProbe(token, probe, describeError) {
|
|
267
|
+
const response = await probe(token);
|
|
268
|
+
if (response.ok) {
|
|
269
|
+
return token;
|
|
270
|
+
}
|
|
271
|
+
// Every path out of here leaves this token unusable, so drop it before
|
|
272
|
+
// taking any of them — that is what lets the next getToken re-prompt.
|
|
273
|
+
// Without it an account with no refresh token to fall back on (Google
|
|
274
|
+
// Drive's implicit flow issues none, and its access tokens last an
|
|
275
|
+
// hour) kept handing the expired token back out of getToken's cached
|
|
276
|
+
// promise: every later request threw this same error, and nothing
|
|
277
|
+
// short of a page reload ever asked the user to log in again.
|
|
278
|
+
self.removeToken();
|
|
279
|
+
const refreshToken = self.retrieveRefreshToken();
|
|
280
|
+
if (!refreshToken) {
|
|
281
|
+
throw new Error(await describeError(response, 'Error validating token'));
|
|
282
|
+
}
|
|
283
|
+
// Exactly once. Recursing here instead looped forever on a resource
|
|
284
|
+
// that fails for a reason no new token fixes — a file the account
|
|
285
|
+
// cannot see — refreshing against the token endpoint on every pass.
|
|
286
|
+
const newToken = await self.exchangeRefreshForAccessToken(refreshToken);
|
|
287
|
+
const retry = await probe(newToken);
|
|
288
|
+
if (!retry.ok) {
|
|
289
|
+
throw new Error(await describeError(retry, 'Error validating token'));
|
|
290
|
+
}
|
|
291
|
+
// removeToken above dropped the cached promise, so storing is enough
|
|
292
|
+
self.storeToken(newToken);
|
|
293
|
+
return newToken;
|
|
294
|
+
},
|
|
295
|
+
/**
|
|
296
|
+
* #action
|
|
297
|
+
* Opens the provider's auth page and returns a promise for the resulting
|
|
298
|
+
* token. For Electron, drives the flow directly via IPC; for web, opens a
|
|
299
|
+
* popup and waits for the redirect message.
|
|
300
|
+
*/
|
|
301
|
+
async getTokenViaAuthFlow() {
|
|
302
|
+
// the slot defaults to '', and `new URL('')` reports only
|
|
303
|
+
// "TypeError: Invalid URL:" — say which account is unconfigured
|
|
304
|
+
if (!self.authEndpoint) {
|
|
305
|
+
throw new Error(`Internet account ${self.internetAccountId} has no authEndpoint configured`);
|
|
306
|
+
}
|
|
307
|
+
const redirectUri = isElectron
|
|
308
|
+
? 'http://localhost/auth'
|
|
309
|
+
: window.location.origin + window.location.pathname;
|
|
310
|
+
// RFC 6749 §10.12: without a state there is nothing tying the redirect
|
|
311
|
+
// back to the request this window started, so a page that can post to
|
|
312
|
+
// us can hand us its own authorization. A configured state is a
|
|
313
|
+
// constant and so guessable — generate a per-flow nonce unless one was
|
|
314
|
+
// set explicitly. Providers are required to echo it back.
|
|
315
|
+
const state = self.state || toBase64Url(randomBytes(16));
|
|
316
|
+
// RFC 7636 §4.1 wants one verifier per authorization request, and this
|
|
317
|
+
// is the request — so it lives here rather than on the model, where a
|
|
318
|
+
// single lazily-created one was shared by every flow the account ever
|
|
319
|
+
// ran. `state` immediately above was already rotating for the same
|
|
320
|
+
// reason.
|
|
321
|
+
const codeVerifier = self.needsPKCE
|
|
322
|
+
? toBase64Url(randomBytes(32))
|
|
323
|
+
: undefined;
|
|
324
|
+
const codeChallenge = codeVerifier
|
|
325
|
+
? await sha256Base64Url(codeVerifier)
|
|
326
|
+
: undefined;
|
|
327
|
+
const data = {
|
|
328
|
+
client_id: self.clientId,
|
|
329
|
+
redirect_uri: redirectUri,
|
|
330
|
+
response_type: self.responseType,
|
|
331
|
+
state,
|
|
332
|
+
...self.authFlowParams,
|
|
333
|
+
...(self.scopes ? { scope: self.scopes } : {}),
|
|
334
|
+
...(codeChallenge
|
|
335
|
+
? { code_challenge: codeChallenge, code_challenge_method: 'S256' }
|
|
336
|
+
: {}),
|
|
337
|
+
};
|
|
338
|
+
const url = new URL(self.authEndpoint);
|
|
339
|
+
url.search = new URLSearchParams(data).toString();
|
|
340
|
+
const oauthParams = {
|
|
341
|
+
internetAccountId: self.internetAccountId,
|
|
342
|
+
expectedState: state,
|
|
343
|
+
exchangeAuthorizationCode: async (code, uri) => self.exchangeAuthorizationForAccessToken(code, uri, codeVerifier),
|
|
344
|
+
storeToken: token => {
|
|
345
|
+
self.storeToken(token);
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
if (isElectron) {
|
|
349
|
+
// @ts-ignore - electron injects require onto window
|
|
350
|
+
const { ipcRenderer } = window.require('electron');
|
|
351
|
+
const electronRedirectUri = await ipcRenderer.invoke('openAuthWindow', {
|
|
352
|
+
internetAccountId: self.internetAccountId,
|
|
353
|
+
data,
|
|
354
|
+
url: url.toString(),
|
|
355
|
+
});
|
|
356
|
+
// the main process resolves undefined when the user closes the auth
|
|
357
|
+
// window without completing the flow
|
|
358
|
+
if (electronRedirectUri === undefined) {
|
|
359
|
+
throw new Error('OAuth flow was cancelled');
|
|
192
360
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
361
|
+
const token = await finishOAuthRedirect(electronRedirectUri, oauthParams);
|
|
362
|
+
if (token === undefined) {
|
|
363
|
+
throw new Error('Electron OAuth flow returned no token');
|
|
196
364
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
365
|
+
return token;
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
const openLoginWindow = () => window.open(url, `JBrowseAuthWindow-${self.internetAccountId}`, 'width=500,height=600,left=0,top=0');
|
|
369
|
+
// no await between the open and this listener, so the redirect
|
|
370
|
+
// message cannot be delivered before we are listening
|
|
371
|
+
const waitForToken = (popup) => waitForOAuthMessage(event => finishOAuthWindow(event, oauthParams), {
|
|
372
|
+
popup,
|
|
373
|
+
isOwnMessage: event => getOAuthRedirectUri(event, self.internetAccountId) !==
|
|
374
|
+
undefined,
|
|
375
|
+
});
|
|
376
|
+
// A popup only opens while the page holds transient activation, and
|
|
377
|
+
// a session that loads an authenticated track has none — so the open
|
|
378
|
+
// is what trips the pop-up blocker, on a share link the user did
|
|
379
|
+
// nothing else to. Ask for the click that carries the activation
|
|
380
|
+
// instead, and don't spend the blocked attempt first where the
|
|
381
|
+
// browser will tell us there is no activation to spend.
|
|
382
|
+
const popup = hasTransientActivation() ? openLoginWindow() : null;
|
|
383
|
+
return popup
|
|
384
|
+
? waitForToken(popup)
|
|
385
|
+
: promptForLoginWindow({
|
|
204
386
|
internetAccountId: self.internetAccountId,
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const eventFromDesktop = new MessageEvent('message', {
|
|
209
|
-
data: { name: eventName, redirectUri: redirectUri },
|
|
387
|
+
session: getRoot(self).session,
|
|
388
|
+
openLoginWindow,
|
|
389
|
+
waitForToken,
|
|
210
390
|
});
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
}))
|
|
394
|
+
.actions(self => ({
|
|
395
|
+
/**
|
|
396
|
+
* #action
|
|
397
|
+
*/
|
|
398
|
+
async getTokenFromUser(resolve, reject) {
|
|
399
|
+
try {
|
|
218
400
|
const refreshToken = self.retrieveRefreshToken();
|
|
219
|
-
let
|
|
401
|
+
let token;
|
|
220
402
|
if (refreshToken) {
|
|
221
403
|
try {
|
|
222
|
-
|
|
223
|
-
resolve(token);
|
|
224
|
-
doUserFlow = false;
|
|
404
|
+
token = await self.exchangeRefreshForAccessToken(refreshToken);
|
|
225
405
|
}
|
|
226
406
|
catch (e) {
|
|
227
407
|
console.error(e);
|
|
228
|
-
self.removeRefreshToken();
|
|
229
408
|
}
|
|
230
409
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (!exchangedTokenPromise) {
|
|
245
|
-
exchangedTokenPromise =
|
|
246
|
-
self.exchangeRefreshForAccessToken(refreshToken);
|
|
247
|
-
}
|
|
248
|
-
const newToken = await exchangedTokenPromise;
|
|
249
|
-
exchangedTokenPromise = undefined;
|
|
250
|
-
return newToken;
|
|
251
|
-
}
|
|
252
|
-
catch (err) {
|
|
253
|
-
console.error('Token could not be refreshed', err);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
throw new Error(await getResponseError({
|
|
257
|
-
response,
|
|
258
|
-
reason: 'Error validating token',
|
|
259
|
-
}));
|
|
260
|
-
}
|
|
261
|
-
return token;
|
|
262
|
-
},
|
|
263
|
-
};
|
|
264
|
-
})
|
|
265
|
-
.actions(self => {
|
|
266
|
-
const superGetFetcher = self.getFetcher;
|
|
267
|
-
return {
|
|
268
|
-
getFetcher(loc) {
|
|
269
|
-
const fetcher = superGetFetcher(loc);
|
|
270
|
-
return async (input, init) => {
|
|
271
|
-
if (loc) {
|
|
272
|
-
await self.validateToken(await self.getToken(loc), loc);
|
|
273
|
-
}
|
|
274
|
-
return fetcher(input, init);
|
|
275
|
-
};
|
|
276
|
-
},
|
|
277
|
-
};
|
|
278
|
-
});
|
|
410
|
+
resolve(token !== undefined ? token : await self.getTokenViaAuthFlow());
|
|
411
|
+
}
|
|
412
|
+
catch (e) {
|
|
413
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
/**
|
|
417
|
+
* #action
|
|
418
|
+
*/
|
|
419
|
+
async validateToken(token, location) {
|
|
420
|
+
return self.validateTokenWithProbe(token, probeToken => fetch(location.uri, self.addAuthHeaderToInit({ method: 'HEAD' }, probeToken)), (response, reason) => getResponseError({ response, reason }));
|
|
421
|
+
},
|
|
422
|
+
}));
|
|
279
423
|
};
|
|
280
424
|
export default stateModelFactory;
|