pryv 3.12.1 → 3.13.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.
- package/README.md +39 -7
- package/package.json +1 -1
- package/src/Auth/AuthController.js +335 -7
- package/src/Auth/AuthStates.js +4 -2
- package/src/Auth/LoginMessages.js +70 -1
- package/src/Auth/ProfileStore.js +119 -0
- package/src/Browser/LoginButton.js +383 -17
- package/src/Service.js +22 -1
- package/src/ServiceAssets.js +3 -0
- package/src/SharedSecrets.js +4 -1
- package/src/index.d.ts +139 -4
- package/src/lib/handoff.js +139 -0
- package/test/AuthController.test.js +210 -0
- package/test/LoginButton.test.js +571 -10
- package/test/LoginMessages.test.js +8 -0
- package/test/ProfileStore.test.js +88 -0
- package/test/Service.accessRequestHandoff.test.js +153 -0
- package/test/browser-index.html +1 -0
package/README.md
CHANGED
|
@@ -603,8 +603,9 @@ The [authentication process](https://api.pryv.com/reference/#authenticate-your-a
|
|
|
603
603
|
2. `INITIALIZED`: visuals assets are loaded, or when [polling](https://api.pryv.com/reference/#poll-request) concludes with **Result: Refused**
|
|
604
604
|
3. `NEED_SIGNIN`: from the response of the [auth request](https://api.pryv.com/reference/#auth-request) through [polling](https://api.pryv.com/reference/#poll-request)
|
|
605
605
|
4. `AUTHORIZED`: When [polling](https://api.pryv.com/reference/#poll-request) concludes with **Result: Accepted**
|
|
606
|
-
5. `SIGNOUT`: when the user
|
|
607
|
-
6. `
|
|
606
|
+
5. `SIGNOUT`: with the account menu, when the user confirms "Log out", just before the client-side authorization credentials are deleted. With `menu: false`, on the click itself, before the "Log out?" question: if the user cancels, the controller re-initializes from the stored credentials (`LOADING` then `AUTHORIZED`)
|
|
607
|
+
6. `SWITCHING`: an account switch started (`{ from, to }`, `to` is `null` when the account is chosen in the sign-in popup); followed by `AUTHORIZED` for the new account, or for the previous one when the switch sign-in is refused. Listeners that ignore it see the usual `NEED_SIGNIN` then `AUTHORIZED` sequence
|
|
608
|
+
7. `ERROR`: see message for more information
|
|
608
609
|
|
|
609
610
|
You will need to provide a function to react depending on the state. The states `NEED_SIGNIN` and `AUTHORIZED` carry the same properties as the [auth process polling responses](https://api.pryv.com/reference/#poll-request). `LOADING`, `INITIALIZED` and `SIGNOUT` only have `status`. The `ERROR` state carries a `message` property.
|
|
610
611
|
|
|
@@ -634,11 +635,8 @@ async onStateChange (state) {
|
|
|
634
635
|
});
|
|
635
636
|
break;
|
|
636
637
|
case AuthStates.SIGNOUT:
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
this.deleteAuthorizationData();
|
|
640
|
-
this.auth.init();
|
|
641
|
-
}
|
|
638
|
+
// Emitted by auth.signOut() after the user confirmed in showMenu()
|
|
639
|
+
// (below); the controller clears the credentials itself.
|
|
642
640
|
break;
|
|
643
641
|
case AuthStates.ERROR:
|
|
644
642
|
this.text = getErrorMessage(this, state.message);
|
|
@@ -661,12 +659,22 @@ The button actions should be handled by the [AuthController](src/Auth/AuthContro
|
|
|
661
659
|
onClick () {
|
|
662
660
|
this.auth.handleClick();
|
|
663
661
|
}
|
|
662
|
+
|
|
663
|
+
// Optional: called on a click once signed in. Ask in your own dialog (never
|
|
664
|
+
// window.confirm), then log out through the controller. Return false to get
|
|
665
|
+
// the SIGNOUT state on the click instead.
|
|
666
|
+
showMenu () {
|
|
667
|
+
myDialog.open({ onLogout: () => this.auth.signOut() });
|
|
668
|
+
return true;
|
|
669
|
+
}
|
|
664
670
|
```
|
|
665
671
|
|
|
666
672
|
```js
|
|
667
673
|
// AuthController.js
|
|
668
674
|
async handleClick () {
|
|
669
675
|
if (isAuthorized.call(this)) {
|
|
676
|
+
// a button implementing showMenu() opens its menu (logout through auth.signOut())
|
|
677
|
+
if (typeof this.loginButton?.showMenu === 'function' && this.loginButton.showMenu() !== false) return;
|
|
670
678
|
this.state = { status: AuthStates.SIGNOUT };
|
|
671
679
|
} else if (isInitialized.call(this)) {
|
|
672
680
|
this.startAuthRequest();
|
|
@@ -679,6 +687,30 @@ async handleClick () {
|
|
|
679
687
|
}
|
|
680
688
|
```
|
|
681
689
|
|
|
690
|
+
##### Account menu
|
|
691
|
+
|
|
692
|
+
Clicking the default button once signed in opens a small account menu: the signed-in username, the service and the app id, **Manage my account** (opens the platform's account app in a new tab) and **Log out**. `SIGNOUT` is emitted once, when "Log out" is chosen.
|
|
693
|
+
|
|
694
|
+
- `authSettings.menu: false` restores the previous flow: `SIGNOUT` on click, then a plain "Log out?" confirmation (a built-in dialog, no longer `window.confirm()`).
|
|
695
|
+
- `authSettings.menu: { hide: ['account', 'info'] }` (or `{ account: false }`) hides entries: `'logout'`, `'account'`, `'switch'`, `'info'`.
|
|
696
|
+
- `authSettings.accountUrl` sets the account app root. Otherwise it is the service's `account` (`service/info`), then the auth page URL of the last sign-in without its trailing `/auth`; when none is known, "Manage my account" is not shown.
|
|
697
|
+
- The menu uses the `.pryv-menu*` CSS classes, which a service's button stylesheet can override; its texts come from the button messages (`MENU_TITLE`, `LOGOUT`, `MANAGE_ACCOUNT`, `APP`, `CLOSE`).
|
|
698
|
+
|
|
699
|
+
The controller exposes the same actions: `auth.signOut()`, `auth.openAccountApp()` and `auth.accountUrl()`. A custom button may implement `showMenu()` to show its own menu (return `false` to fall back to `SIGNOUT`).
|
|
700
|
+
|
|
701
|
+
##### Several accounts and account switching
|
|
702
|
+
|
|
703
|
+
The button remembers the accounts signed in on the app (at most `authSettings.maxProfiles`, default 5; the least recently used is forgotten first). The default button keeps the active account in its usual cookie (`pryv-libjs-<appId>`, `apiEndpoint` + `username`, removed when no account is active) and the remembered accounts in a second one (`pryv-libjs-<appId>-profiles`), so a version without account switching never reads the list as a signed-in account. `getAuthorizationData()` returns both merged (`profiles` next to the active account); a custom button stores that object as it is.
|
|
704
|
+
|
|
705
|
+
- The menu lists them under "Use this app for". Choosing one activates it without a sign-in when its access is still valid (checked with `access-info`); an access that was revoked (for example by the end of an account delegation) is marked "no longer available" and asked for again through the sign-in popup.
|
|
706
|
+
- On platforms with account delegation (`features.delegation` in `service/info`), "Another account..." runs the auth request again with `actAs: 'allow'`: after signing in, the popup asks whom the access is for (the user's own account or an account they control). An access granted for a controlled account is shown as `kim (via parent)`, and the menu offers "Switch back to parent". The app receives an access on the controlled account, never the delegate's own credentials; `connection.accessInfo().delegation` tells it so authoritatively.
|
|
707
|
+
- "Log out" logs out of the active account and keeps the others (no account is signed in afterwards); "Log out of all accounts", shown when several are remembered, forgets them all.
|
|
708
|
+
- `authSettings.authRequest.actAs`: `'allow'` (the server default: the popup may offer the accounts the user controls), `'deny'` (never), or a username to preselect. An app that supplies its own fixed access `token` and lets users switch accounts ends up with the same token value on several accounts; use `actAs: 'deny'` to avoid it.
|
|
709
|
+
|
|
710
|
+
A switch that needs no sign-in, and the return to the previous account after a switch that did not complete, end in an `AUTHORIZED` state without a `key`: like the sign-in from stored credentials on page load, it carries the stored `username` and `apiEndpoint`.
|
|
711
|
+
|
|
712
|
+
Controller API: `auth.switchTo(username)` (`null`: the user's own account), `auth.addAccount()`, `auth.profiles()` (`[{ username, actingAs?, active, available }]`), `auth.currentProfile()` and `auth.signOut({ all: true })`.
|
|
713
|
+
|
|
682
714
|
##### Custom button usage
|
|
683
715
|
|
|
684
716
|
You must then provide this class as follows:
|
package/package.json
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
const utils = require('../utils');
|
|
6
6
|
const AuthStates = require('./AuthStates');
|
|
7
7
|
const Messages = require('./LoginMessages');
|
|
8
|
+
const ProfileStore = require('./ProfileStore');
|
|
9
|
+
const handoff = require('../lib/handoff');
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Controller for authentication flow
|
|
@@ -39,6 +41,11 @@ class AuthController {
|
|
|
39
41
|
this.messages = Messages(this.languageCode);
|
|
40
42
|
|
|
41
43
|
this.loginButton = loginButton;
|
|
44
|
+
// Incremented by every new auth request, sign-out and re-initialization:
|
|
45
|
+
// an older request's poll then no longer changes the state.
|
|
46
|
+
this._authFlowId = 0;
|
|
47
|
+
/** @type {Object|null} signed-in state to return to if a switch does not complete */
|
|
48
|
+
this._switchPrevious = null;
|
|
42
49
|
|
|
43
50
|
function validateSettings (settings) {
|
|
44
51
|
if (!settings) { throw new Error('settings cannot be null'); }
|
|
@@ -55,6 +62,18 @@ class AuthController {
|
|
|
55
62
|
if (!settings.authRequest.requestedPermissions) {
|
|
56
63
|
throw new Error('Missing settings.authRequest.requestedPermissions');
|
|
57
64
|
}
|
|
65
|
+
|
|
66
|
+
// Delivery mode. Default to the one-time shared-secret hand-off so the
|
|
67
|
+
// token is never returned in the poll: a new core echoes
|
|
68
|
+
// `credentialHandoff` and delivers a `handoff` key (redeemed once here),
|
|
69
|
+
// an older core drops the field and falls back to inline delivery.
|
|
70
|
+
// Opt out with `authRequest.credentialHandoff = 'inline'`, which sends no
|
|
71
|
+
// field at all, so the request is byte-identical to the legacy one.
|
|
72
|
+
if (settings.authRequest.credentialHandoff === 'inline') {
|
|
73
|
+
delete settings.authRequest.credentialHandoff;
|
|
74
|
+
} else if (settings.authRequest.credentialHandoff == null) {
|
|
75
|
+
settings.authRequest.credentialHandoff = 'shared-secret';
|
|
76
|
+
}
|
|
58
77
|
}
|
|
59
78
|
}
|
|
60
79
|
|
|
@@ -63,6 +82,7 @@ class AuthController {
|
|
|
63
82
|
* @returns {Promise<Service>} Promise resolving to the Service instance
|
|
64
83
|
*/
|
|
65
84
|
async init () {
|
|
85
|
+
cancelAuthFlow(this);
|
|
66
86
|
this.serviceInfo = this.service.infoSync();
|
|
67
87
|
this.state = { status: AuthStates.LOADING };
|
|
68
88
|
this.assets = await loadAssets(this);
|
|
@@ -99,6 +119,8 @@ class AuthController {
|
|
|
99
119
|
* Stops poll for auth request
|
|
100
120
|
*/
|
|
101
121
|
stopAuthRequest (msg) {
|
|
122
|
+
// its poll must not change the state any more
|
|
123
|
+
cancelAuthFlow(this);
|
|
102
124
|
this.state = { status: AuthStates.ERROR, message: msg };
|
|
103
125
|
}
|
|
104
126
|
|
|
@@ -108,9 +130,19 @@ class AuthController {
|
|
|
108
130
|
*/
|
|
109
131
|
async handleClick () {
|
|
110
132
|
if (isAuthorized.call(this)) {
|
|
133
|
+
// A button with an account menu opens it (logout happens from there,
|
|
134
|
+
// through `signOut()`); otherwise the legacy SIGNOUT state lets the
|
|
135
|
+
// button confirm the logout itself.
|
|
136
|
+
const loginButton = this.loginButton;
|
|
137
|
+
if (loginButton != null && typeof loginButton.showMenu === 'function' &&
|
|
138
|
+
loginButton.showMenu() !== false) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
111
141
|
this.state = { status: AuthStates.SIGNOUT };
|
|
112
142
|
} else if (isInitialized.call(this)) {
|
|
113
143
|
this.startAuthRequest();
|
|
144
|
+
} else if (this.state.status === AuthStates.SWITCHING) {
|
|
145
|
+
// a switch is running; its outcome arrives as a state change
|
|
114
146
|
} else if (isNeedSignIn.call(this)) {
|
|
115
147
|
// reopen popup (HACK for now: set to private property to avoid self-assignment)
|
|
116
148
|
this.state = this._state;
|
|
@@ -129,6 +161,192 @@ class AuthController {
|
|
|
129
161
|
}
|
|
130
162
|
}
|
|
131
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Log out: emit SIGNOUT once, forget the active account and return to
|
|
166
|
+
* INITIALIZED. The other remembered accounts are kept (`all` forgets them
|
|
167
|
+
* too). This is the confirmed logout (no further confirmation).
|
|
168
|
+
* @param {Object} [options]
|
|
169
|
+
* @param {boolean} [options.all] - forget every remembered account
|
|
170
|
+
* @returns {Promise<void>}
|
|
171
|
+
*/
|
|
172
|
+
async signOut (options) {
|
|
173
|
+
const all = options?.all === true;
|
|
174
|
+
cancelAuthFlow(this);
|
|
175
|
+
const store = this._readProfiles();
|
|
176
|
+
// during a switch, the account being left
|
|
177
|
+
const current = this.state?.username ?? this.state?.from;
|
|
178
|
+
this._signingOut = true;
|
|
179
|
+
try {
|
|
180
|
+
this.state = { status: AuthStates.SIGNOUT };
|
|
181
|
+
} finally {
|
|
182
|
+
this._signingOut = false;
|
|
183
|
+
}
|
|
184
|
+
// Drop any cached hand-off credential for this flow's key. Guard the
|
|
185
|
+
// key: on a cookie-autologin session there is no `_authFlowKey`, and
|
|
186
|
+
// clearing with `undefined` would wipe an unrelated concurrent flow.
|
|
187
|
+
if (this._authFlowKey != null) handoff.cacheClear(this._authFlowKey);
|
|
188
|
+
const others = current == null ? store.profiles : ProfileStore.remove(store, current).profiles;
|
|
189
|
+
const loginButton = this.loginButton;
|
|
190
|
+
if (!all && others.length > 0 && loginButton != null && typeof loginButton.saveAuthorizationData === 'function') {
|
|
191
|
+
loginButton.saveAuthorizationData(ProfileStore.write(Object.assign({}, store, { active: null, profiles: others })));
|
|
192
|
+
} else if (loginButton != null && typeof loginButton.deleteAuthorizationData === 'function') {
|
|
193
|
+
await loginButton.deleteAuthorizationData();
|
|
194
|
+
}
|
|
195
|
+
await this.init();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The accounts remembered for this app, most recently used first.
|
|
200
|
+
* @returns {Array<{username: string, actingAs?: {username: string, delegate: string}, active: boolean, available: boolean}>}
|
|
201
|
+
*/
|
|
202
|
+
profiles () {
|
|
203
|
+
const current = this.currentProfile();
|
|
204
|
+
return this._readProfiles().profiles.map((p) => {
|
|
205
|
+
const out = { username: p.username, active: current != null && current.username === p.username, available: p.unavailable !== true };
|
|
206
|
+
if (p.actingAs != null) out.actingAs = p.actingAs;
|
|
207
|
+
return out;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The signed-in account, or null.
|
|
213
|
+
* @returns {{username: string, actingAs?: {username: string, delegate: string}}|null}
|
|
214
|
+
*/
|
|
215
|
+
currentProfile () {
|
|
216
|
+
if (this.state?.status !== AuthStates.AUTHORIZED || this.state.profile == null) return null;
|
|
217
|
+
const out = { username: this.state.profile.username };
|
|
218
|
+
if (this.state.profile.actingAs != null) out.actingAs = this.state.profile.actingAs;
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Switch to another account. `username` null means the signed-in person's
|
|
224
|
+
* own account (switch back). A remembered account whose access is still
|
|
225
|
+
* valid is activated without a sign-in; otherwise the auth request runs
|
|
226
|
+
* again, asking for that account (`actAs`). Emits SWITCHING first; ends in
|
|
227
|
+
* AUTHORIZED, or back on the previous account when the sign-in is refused.
|
|
228
|
+
* @param {string|null} username
|
|
229
|
+
* @returns {Promise<void>}
|
|
230
|
+
*/
|
|
231
|
+
async switchTo (username) {
|
|
232
|
+
const store = this._readProfiles();
|
|
233
|
+
const current = this.currentProfile();
|
|
234
|
+
let target;
|
|
235
|
+
if (username == null) {
|
|
236
|
+
if (current != null && current.actingAs == null) return;
|
|
237
|
+
const delegate = current?.actingAs?.delegate;
|
|
238
|
+
// Acting for an account: back to the delegate's own account only, never
|
|
239
|
+
// to another remembered one. Not signed in: the most recent own account.
|
|
240
|
+
target = delegate != null
|
|
241
|
+
? store.profiles.find((p) => p.username === delegate && p.actingAs == null)
|
|
242
|
+
: store.profiles.find((p) => p.actingAs == null);
|
|
243
|
+
} else {
|
|
244
|
+
if (current != null && current.username === username) return;
|
|
245
|
+
target = store.profiles.find((p) => p.username === username);
|
|
246
|
+
}
|
|
247
|
+
const toUsername = target?.username ?? username ?? null;
|
|
248
|
+
const previous = restorableState(this.state);
|
|
249
|
+
// a log out or re-initialization during the access check ends this switch
|
|
250
|
+
cancelAuthFlow(this);
|
|
251
|
+
const flowId = this._authFlowId;
|
|
252
|
+
this.state = { status: AuthStates.SWITCHING, from: current?.username ?? null, to: toUsername };
|
|
253
|
+
|
|
254
|
+
if (target != null && target.unavailable !== true) {
|
|
255
|
+
let info;
|
|
256
|
+
try {
|
|
257
|
+
info = await this._accessInfo(target.apiEndpoint);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
if (this._authFlowId !== flowId) throw e;
|
|
260
|
+
// network failure: stay on the previous account
|
|
261
|
+
this.state = previous ?? { status: AuthStates.INITIALIZED, serviceInfo: this.serviceInfo };
|
|
262
|
+
throw e;
|
|
263
|
+
}
|
|
264
|
+
if (this._authFlowId !== flowId) return;
|
|
265
|
+
if (info != null && info.error == null) {
|
|
266
|
+
const profile = profileFromAccessInfo(target, info);
|
|
267
|
+
this.state = { status: AuthStates.AUTHORIZED, username: profile.username, apiEndpoint: profile.apiEndpoint, profile };
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (!ACCESS_GONE_ERRORS.includes(info?.error?.id)) {
|
|
271
|
+
// any other answer (server error, rate limit) says nothing about the access
|
|
272
|
+
this.state = previous ?? { status: AuthStates.INITIALIZED, serviceInfo: this.serviceInfo };
|
|
273
|
+
throw new Error('Cannot check the access of ' + target.username + ': ' + JSON.stringify(info?.error));
|
|
274
|
+
}
|
|
275
|
+
// revoked or expired (a detach revokes the accesses granted through it)
|
|
276
|
+
this._saveProfiles(ProfileStore.markUnavailable(this._readProfiles(), target.username));
|
|
277
|
+
}
|
|
278
|
+
// own account: a sign-in that offers no other account; otherwise ask for that one
|
|
279
|
+
await this.startAuthRequest({ actAs: username == null ? 'deny' : username }, previous);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Sign in to one more account (the popup may offer the accounts the person
|
|
284
|
+
* can act for); the remembered accounts are kept.
|
|
285
|
+
* @returns {Promise<void>}
|
|
286
|
+
*/
|
|
287
|
+
async addAccount () {
|
|
288
|
+
const current = this.currentProfile();
|
|
289
|
+
const previous = restorableState(this.state);
|
|
290
|
+
this.state = { status: AuthStates.SWITCHING, from: current?.username ?? null, to: null };
|
|
291
|
+
await this.startAuthRequest({ actAs: 'allow' }, previous);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* @private The access-info of a stored account (`{ error }` when refused).
|
|
296
|
+
* @param {string} apiEndpoint
|
|
297
|
+
*/
|
|
298
|
+
async _accessInfo (apiEndpoint) {
|
|
299
|
+
// required here: Connection is not needed before the first switch
|
|
300
|
+
const Connection = require('../Connection');
|
|
301
|
+
return await new Connection(apiEndpoint).accessInfo(true);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** @private */
|
|
305
|
+
_readProfiles () {
|
|
306
|
+
const loginButton = this.loginButton;
|
|
307
|
+
if (loginButton == null || typeof loginButton.getAuthorizationData !== 'function') return ProfileStore.read(null);
|
|
308
|
+
return ProfileStore.read(loginButton.getAuthorizationData());
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** @private */
|
|
312
|
+
_saveProfiles (store) {
|
|
313
|
+
const loginButton = this.loginButton;
|
|
314
|
+
if (loginButton == null || typeof loginButton.saveAuthorizationData !== 'function') return;
|
|
315
|
+
const data = ProfileStore.write(store);
|
|
316
|
+
if (data == null && typeof loginButton.deleteAuthorizationData === 'function') {
|
|
317
|
+
loginButton.deleteAuthorizationData();
|
|
318
|
+
} else if (data != null) {
|
|
319
|
+
loginButton.saveAuthorizationData(data);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* URL of the account app (profile page) for this platform, or null when
|
|
325
|
+
* it cannot be determined. Resolution order: `settings.accountUrl`, then
|
|
326
|
+
* the service's `account`, then the auth page URL of the last auth
|
|
327
|
+
* request with its trailing `/auth` removed.
|
|
328
|
+
* @returns {string|null}
|
|
329
|
+
*/
|
|
330
|
+
accountUrl () {
|
|
331
|
+
let base = this.settings.accountUrl || this.serviceInfo?.account || accountUrlFromAuthUrl(this._authUrl);
|
|
332
|
+
if (typeof base !== 'string' || base === '') return null;
|
|
333
|
+
base = base.replace(/\/+$/, '');
|
|
334
|
+
// @ts-ignore - Service keeps the URL it was created with
|
|
335
|
+
const serviceInfoUrl = this.service?._serviceInfoUrl;
|
|
336
|
+
return base + '/account/profile' +
|
|
337
|
+
(serviceInfoUrl ? '?pryvServiceInfoUrl=' + encodeURIComponent(serviceInfoUrl) : '');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Open the account app in a new tab.
|
|
342
|
+
* @returns {string|null} the URL opened, or null when unknown
|
|
343
|
+
*/
|
|
344
|
+
openAccountApp () {
|
|
345
|
+
const url = this.accountUrl();
|
|
346
|
+
if (url != null) window.open(url, '_blank', 'noopener');
|
|
347
|
+
return url;
|
|
348
|
+
}
|
|
349
|
+
|
|
132
350
|
/**
|
|
133
351
|
* Compute the return URL for authentication redirect.
|
|
134
352
|
* Used only in browser environments.
|
|
@@ -174,16 +392,26 @@ class AuthController {
|
|
|
174
392
|
|
|
175
393
|
/**
|
|
176
394
|
* Start the authentication request and polling process
|
|
395
|
+
* @param {Object} [overrides] - auth request fields for this request only (e.g. `actAs`)
|
|
396
|
+
* @param {Object} [previous] - AUTHORIZED state to return to when this
|
|
397
|
+
* request (an account switch) does not end in AUTHORIZED
|
|
177
398
|
* @returns {Promise<void>}
|
|
178
399
|
* @see https://pryv.github.io/reference/#auth-request
|
|
179
400
|
*/
|
|
180
|
-
async startAuthRequest () {
|
|
401
|
+
async startAuthRequest (overrides, previous) {
|
|
402
|
+
cancelAuthFlow(this);
|
|
403
|
+
const flowId = this._authFlowId;
|
|
404
|
+
this._switchPrevious = previous ?? null;
|
|
181
405
|
// @ts-ignore - postAccess uses .call(this) for context
|
|
182
|
-
|
|
406
|
+
const requested = await postAccess.call(this);
|
|
407
|
+
if (this._authFlowId !== flowId) return; // replaced while posting
|
|
408
|
+
this.state = requested;
|
|
183
409
|
// Remember the polling key so listeners on the terminal AUTHORIZED
|
|
184
410
|
// state can be handed `{ key, serviceInfo? }` (the polling response
|
|
185
411
|
// itself doesn't echo `key` back).
|
|
186
412
|
this._authFlowKey = this.state?.key;
|
|
413
|
+
// Kept to locate the account app when the service does not name it.
|
|
414
|
+
if (this.state?.authUrl) this._authUrl = this.state.authUrl;
|
|
187
415
|
|
|
188
416
|
await doPolling.call(this);
|
|
189
417
|
|
|
@@ -194,14 +422,17 @@ class AuthController {
|
|
|
194
422
|
// @ts-ignore - this is bound via .call()
|
|
195
423
|
this.serviceInfo.access,
|
|
196
424
|
// @ts-ignore - this is bound via .call()
|
|
197
|
-
this.settings.authRequest
|
|
425
|
+
Object.assign({}, this.settings.authRequest, overrides)
|
|
198
426
|
);
|
|
199
427
|
if (!response.ok) {
|
|
200
428
|
throw new Error('Access request failed: ' + JSON.stringify(body));
|
|
201
429
|
}
|
|
202
430
|
return body;
|
|
203
431
|
} catch (e) {
|
|
204
|
-
this.
|
|
432
|
+
if (this._authFlowId !== flowId) throw e; // replaced while posting
|
|
433
|
+
const previous = this._switchPrevious;
|
|
434
|
+
this._switchPrevious = null;
|
|
435
|
+
this.state = previous ?? {
|
|
205
436
|
status: AuthStates.ERROR,
|
|
206
437
|
message: 'Requesting access',
|
|
207
438
|
error: e
|
|
@@ -213,21 +444,59 @@ class AuthController {
|
|
|
213
444
|
/** @this {AuthController} */
|
|
214
445
|
async function doPolling () {
|
|
215
446
|
// @ts-ignore - this is bound via .call()
|
|
216
|
-
if (this.state?.status !== AuthStates.NEED_SIGNIN) {
|
|
447
|
+
if (this._authFlowId !== flowId || this.state?.status !== AuthStates.NEED_SIGNIN) {
|
|
217
448
|
return;
|
|
218
449
|
}
|
|
219
450
|
// @ts-ignore - this is bound via .call()
|
|
220
451
|
const pollResponse = await pollAccess(this.state?.poll);
|
|
452
|
+
// a newer request, a sign-out or a re-initialization replaced this one
|
|
453
|
+
// @ts-ignore - this is bound via .call()
|
|
454
|
+
if (this._authFlowId !== flowId) return;
|
|
221
455
|
|
|
222
456
|
if (pollResponse.status === AuthStates.NEED_SIGNIN) {
|
|
223
457
|
// @ts-ignore - this is bound via .call()
|
|
224
458
|
setTimeout(await doPolling.bind(this), this.state?.poll_rate_ms);
|
|
225
459
|
} else {
|
|
460
|
+
// Shared-secret delivery: the ACCEPTED body carries a one-time
|
|
461
|
+
// `handoff` key, not the token. Redeem it once here (caching under the
|
|
462
|
+
// poll key so a later connectFromKey reuses it) and rewrite the body to
|
|
463
|
+
// the legacy shape, so the cookie / LoginButton path and the external
|
|
464
|
+
// listener filter are untouched.
|
|
465
|
+
if (handoff.isHandoffBody(pollResponse)) {
|
|
466
|
+
try {
|
|
467
|
+
const entry = await handoff.resolveHandoff(pollResponse, this._authFlowKey);
|
|
468
|
+
pollResponse.apiEndpoint = entry.apiEndpoint;
|
|
469
|
+
pollResponse.token = entry.token;
|
|
470
|
+
pollResponse.username = entry.username;
|
|
471
|
+
delete pollResponse.handoff;
|
|
472
|
+
} catch (e) {
|
|
473
|
+
// @ts-ignore - this is bound via .call()
|
|
474
|
+
if (this._authFlowId !== flowId) return;
|
|
475
|
+
// @ts-ignore - this is bound via .call()
|
|
476
|
+
const previous = this._switchPrevious;
|
|
477
|
+
// @ts-ignore - this is bound via .call()
|
|
478
|
+
this._switchPrevious = null;
|
|
479
|
+
this.state = previous ?? { status: AuthStates.ERROR, message: 'Credential hand-off failed', error: e };
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
// a newer request, a sign-out or a re-initialization replaced this one
|
|
483
|
+
// @ts-ignore - this is bound via .call()
|
|
484
|
+
if (this._authFlowId !== flowId) return;
|
|
485
|
+
}
|
|
226
486
|
// Carry the key forward — listeners on the narrow public surface
|
|
227
487
|
// need it, and the server doesn't echo it back on ACCEPTED.
|
|
228
488
|
if (this._authFlowKey != null && pollResponse.key == null) {
|
|
229
489
|
pollResponse.key = this._authFlowKey;
|
|
230
490
|
}
|
|
491
|
+
const previous = this._switchPrevious;
|
|
492
|
+
this._switchPrevious = null;
|
|
493
|
+
if (pollResponse.status === AuthStates.AUTHORIZED) {
|
|
494
|
+
pollResponse.profile = ProfileStore.fromAccepted(pollResponse);
|
|
495
|
+
} else if (previous != null) {
|
|
496
|
+
// an account switch that did not complete: stay on the previous account
|
|
497
|
+
this.state = previous;
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
231
500
|
this.state = pollResponse;
|
|
232
501
|
}
|
|
233
502
|
|
|
@@ -307,6 +576,22 @@ function filterForExternalListener (state) {
|
|
|
307
576
|
return out;
|
|
308
577
|
}
|
|
309
578
|
|
|
579
|
+
/**
|
|
580
|
+
* The account app is served next to the auth page: strip a trailing `/auth`
|
|
581
|
+
* path segment (and the query) from the auth page URL. Null when the URL
|
|
582
|
+
* does not have that shape.
|
|
583
|
+
* @param {string} [authUrl]
|
|
584
|
+
* @returns {string|null}
|
|
585
|
+
*/
|
|
586
|
+
function accountUrlFromAuthUrl (authUrl) {
|
|
587
|
+
if (typeof authUrl !== 'string') return null;
|
|
588
|
+
let url;
|
|
589
|
+
try { url = new URL(authUrl); } catch (e) { return null; }
|
|
590
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
591
|
+
if (!path.endsWith('/auth')) return null;
|
|
592
|
+
return url.origin + path.slice(0, -'/auth'.length);
|
|
593
|
+
}
|
|
594
|
+
|
|
310
595
|
async function checkAutoLogin (authController) {
|
|
311
596
|
const loginButton = authController.loginButton;
|
|
312
597
|
if (loginButton == null) {
|
|
@@ -314,9 +599,52 @@ async function checkAutoLogin (authController) {
|
|
|
314
599
|
}
|
|
315
600
|
|
|
316
601
|
const storedCredentials = await loginButton.getAuthorizationData();
|
|
317
|
-
if (storedCredentials
|
|
318
|
-
|
|
602
|
+
if (storedCredentials == null) return;
|
|
603
|
+
if (typeof storedCredentials.authUrl === 'string') authController._authUrl = storedCredentials.authUrl;
|
|
604
|
+
if (Array.isArray(storedCredentials.profiles)) {
|
|
605
|
+
// Several remembered accounts: sign in to the active one, if any
|
|
606
|
+
const store = ProfileStore.read(storedCredentials);
|
|
607
|
+
if (store.active == null) return;
|
|
608
|
+
const state = { status: AuthStates.AUTHORIZED, username: store.active.username, apiEndpoint: store.active.apiEndpoint, profile: store.active };
|
|
609
|
+
if (store.authUrl != null) state.authUrl = store.authUrl;
|
|
610
|
+
authController.state = state;
|
|
611
|
+
return;
|
|
319
612
|
}
|
|
613
|
+
const state = Object.assign({}, { status: AuthStates.AUTHORIZED }, storedCredentials);
|
|
614
|
+
if (typeof state.username === 'string' && typeof state.apiEndpoint === 'string') state.profile = ProfileStore.profileOf(state);
|
|
615
|
+
authController.state = state;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** A stored profile refreshed with what its access says about itself. */
|
|
619
|
+
function profileFromAccessInfo (stored, info) {
|
|
620
|
+
const profile = { username: stored.username, apiEndpoint: stored.apiEndpoint };
|
|
621
|
+
const d = info.delegation;
|
|
622
|
+
if (d != null && d.isDelegatedAccess === true && typeof d.delegate?.username === 'string') {
|
|
623
|
+
profile.actingAs = { username: stored.username, delegate: d.delegate.username };
|
|
624
|
+
}
|
|
625
|
+
return profile;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** API errors that mean a stored access is no longer usable. */
|
|
629
|
+
const ACCESS_GONE_ERRORS = ['invalid-access-token', 'forbidden'];
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* The signed-in state to return to when an account switch does not
|
|
633
|
+
* complete: the account as stored, without the `key` of its sign-in (that
|
|
634
|
+
* auth request is consumed), like a sign-in from stored credentials.
|
|
635
|
+
*/
|
|
636
|
+
function restorableState (state) {
|
|
637
|
+
if (state?.status !== AuthStates.AUTHORIZED) return null;
|
|
638
|
+
const restored = { status: AuthStates.AUTHORIZED, username: state.username, apiEndpoint: state.apiEndpoint };
|
|
639
|
+
if (state.profile != null) restored.profile = state.profile;
|
|
640
|
+
if (state.authUrl != null) restored.authUrl = state.authUrl;
|
|
641
|
+
return restored;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Stop any auth request in progress: its poll no longer changes the state. */
|
|
645
|
+
function cancelAuthFlow (authController) {
|
|
646
|
+
authController._authFlowId = (authController._authFlowId || 0) + 1;
|
|
647
|
+
authController._switchPrevious = null;
|
|
320
648
|
}
|
|
321
649
|
|
|
322
650
|
// ------------------ ACTIONS ----------- //
|
package/src/Auth/AuthStates.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
/**
|
|
6
6
|
* The possible auth states:
|
|
7
|
-
* ERROR, LOADING, INITIALIZED, NEED_SIGNIN, AUTHORIZED, SIGNOUT, REFUSED
|
|
7
|
+
* ERROR, LOADING, INITIALIZED, NEED_SIGNIN, AUTHORIZED, SIGNOUT, REFUSED,
|
|
8
|
+
* SWITCHING (an account switch is running)
|
|
8
9
|
* @readonly
|
|
9
10
|
* @enum {string}
|
|
10
11
|
* @memberof pryv.Browser
|
|
@@ -16,5 +17,6 @@ module.exports = {
|
|
|
16
17
|
NEED_SIGNIN: 'NEED_SIGNIN',
|
|
17
18
|
AUTHORIZED: 'ACCEPTED',
|
|
18
19
|
SIGNOUT: 'SIGNOUT',
|
|
19
|
-
REFUSED: 'REFUSED'
|
|
20
|
+
REFUSED: 'REFUSED',
|
|
21
|
+
SWITCHING: 'SWITCHING'
|
|
20
22
|
};
|
|
@@ -19,11 +19,80 @@ const Messages = {
|
|
|
19
19
|
SIGNOUT_CONFIRM: {
|
|
20
20
|
en: 'Logout?',
|
|
21
21
|
fr: 'Se déconnecter ?'
|
|
22
|
+
},
|
|
23
|
+
MENU_TITLE: {
|
|
24
|
+
en: 'Account',
|
|
25
|
+
fr: 'Compte'
|
|
26
|
+
},
|
|
27
|
+
LOGOUT: {
|
|
28
|
+
en: 'Log out',
|
|
29
|
+
fr: 'Se déconnecter'
|
|
30
|
+
},
|
|
31
|
+
MANAGE_ACCOUNT: {
|
|
32
|
+
en: 'Manage my account',
|
|
33
|
+
fr: 'Gérer mon compte'
|
|
34
|
+
},
|
|
35
|
+
APP: {
|
|
36
|
+
en: 'app',
|
|
37
|
+
fr: 'app'
|
|
38
|
+
},
|
|
39
|
+
CLOSE: {
|
|
40
|
+
en: 'Close',
|
|
41
|
+
fr: 'Fermer'
|
|
42
|
+
},
|
|
43
|
+
CANCEL: {
|
|
44
|
+
en: 'Cancel',
|
|
45
|
+
fr: 'Annuler'
|
|
46
|
+
},
|
|
47
|
+
LOGOUT_ALL: {
|
|
48
|
+
en: 'Log out of all accounts',
|
|
49
|
+
fr: 'Se déconnecter de tous les comptes'
|
|
50
|
+
},
|
|
51
|
+
MANAGE_ACCOUNT_OF: {
|
|
52
|
+
en: 'Manage {username}\'s account',
|
|
53
|
+
fr: 'Gérer le compte de {username}'
|
|
54
|
+
},
|
|
55
|
+
USE_FOR: {
|
|
56
|
+
en: 'Use this app for',
|
|
57
|
+
fr: 'Utiliser cette app pour'
|
|
58
|
+
},
|
|
59
|
+
ME: {
|
|
60
|
+
en: 'me',
|
|
61
|
+
fr: 'moi'
|
|
62
|
+
},
|
|
63
|
+
VIA: {
|
|
64
|
+
en: 'via',
|
|
65
|
+
fr: 'via'
|
|
66
|
+
},
|
|
67
|
+
ACTING_AS: {
|
|
68
|
+
en: 'acting as',
|
|
69
|
+
fr: 'pour le compte'
|
|
70
|
+
},
|
|
71
|
+
SWITCH_BACK: {
|
|
72
|
+
en: 'Switch back to {username}',
|
|
73
|
+
fr: 'Revenir à {username}'
|
|
74
|
+
},
|
|
75
|
+
SWITCHING: {
|
|
76
|
+
en: 'Switching...',
|
|
77
|
+
fr: 'Changement...'
|
|
78
|
+
},
|
|
79
|
+
OTHER_ACCOUNT: {
|
|
80
|
+
en: 'Another account...',
|
|
81
|
+
fr: 'Un autre compte...'
|
|
82
|
+
},
|
|
83
|
+
UNAVAILABLE: {
|
|
84
|
+
en: 'no longer available',
|
|
85
|
+
fr: 'plus disponible'
|
|
22
86
|
}
|
|
23
87
|
};
|
|
24
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Messages for a language. `definitions` (a service's own messages) override
|
|
91
|
+
* the defaults key by key, so a key the service does not define still has
|
|
92
|
+
* its default text.
|
|
93
|
+
*/
|
|
25
94
|
function get (languageCode, definitions) {
|
|
26
|
-
const myMessages = definitions ||
|
|
95
|
+
const myMessages = Object.assign({}, Messages, definitions || {});
|
|
27
96
|
const res = {};
|
|
28
97
|
Object.keys(myMessages).forEach((key) => {
|
|
29
98
|
res[key] = myMessages[key][languageCode] || myMessages[key].en;
|