pryv 3.12.1 → 3.14.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/README.md +42 -8
- package/package.json +1 -1
- package/src/Auth/AuthController.js +366 -13
- package/src/Auth/AuthStates.js +4 -2
- package/src/Auth/LoginMessages.js +70 -1
- package/src/Auth/ProfileStore.js +137 -0
- package/src/Auth/index.js +5 -2
- package/src/Browser/LoginButton.js +509 -33
- package/src/Connection.js +19 -13
- package/src/Service.js +49 -16
- package/src/ServiceAssets.js +3 -0
- package/src/SharedSecrets.js +4 -1
- package/src/index.d.ts +172 -11
- package/src/lib/handoff.js +139 -0
- package/src/lib/pollUrls.js +71 -0
- package/src/utils.js +7 -1
- package/test/AuthController.test.js +321 -5
- package/test/Connection.apiOneError.test.js +105 -0
- package/test/LoginButton.test.js +573 -10
- package/test/LoginButton.urlCleanup.test.js +453 -14
- package/test/LoginMessages.test.js +8 -0
- package/test/ProfileStore.test.js +88 -0
- package/test/Service.accessRequest.test.js +4 -4
- package/test/Service.accessRequestHandoff.test.js +153 -0
- package/test/Service.errorBodies.test.js +82 -0
- package/test/Service.mfa.test.js +4 -4
- package/test/Service.pollUrlByKey.test.js +114 -0
- package/test/browser-index.html +1 -0
package/README.md
CHANGED
|
@@ -603,10 +603,11 @@ 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
|
-
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
|
+
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, and an `error` (with an `id` such as `'unexpected-auth-return'` when available); a click on the button in `ERROR` starts over.
|
|
610
611
|
|
|
611
612
|
```js
|
|
612
613
|
async onStateChange (state) {
|
|
@@ -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:
|
|
@@ -700,6 +732,8 @@ For a more advanced scenario, you can check the default button implementation in
|
|
|
700
732
|
|
|
701
733
|
There is a possibility that you would like to register the user in another page. You can find an example [here](https://github.com/pryv/lib-js/blob/master/examples/auth-with-redirection.html), and try it running [there](https://api.pryv.com/lib-js/examples/auth-with-redirection.html). Again, to run these examples locally, see below.
|
|
702
734
|
|
|
735
|
+
Set `authRequest.returnURL`: `'self#'` always redirects and comes back to the current page, `'auto#'` (the default) opens a popup on desktop and redirects on a phone or tablet, and a URL always redirects and comes back to that URL. Before leaving, the button keeps the auth request it started in `sessionStorage`; on the way back it finishes only that request, in the same tab and on the same origin, and any other return ends in `ERROR` with `error.id` `'unexpected-auth-return'` (or keeps the account the page is already signed in to). If your app starts the auth request itself (`Service.startAccessRequest`) and comes back to a page with the button, finish the sign-in yourself: poll the `poll` URL the request returned with `Service.pollAccessRequest(pollUrl)`. `Service.connectFromKey(key)` reaches the right core only within the page load (or Node process) that started the request, or after the button finished a redirect sign-in it started; after your own redirect (the page reloads) on a multi-core platform, pass the poll URL instead, since a key alone may reach a core that does not hold the request.
|
|
736
|
+
|
|
703
737
|
|
|
704
738
|
### Running examples locally
|
|
705
739
|
|
package/package.json
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
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');
|
|
10
|
+
const pollUrls = require('../lib/pollUrls');
|
|
11
|
+
const PryvError = require('../lib/PryvError');
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* Controller for authentication flow
|
|
@@ -39,6 +43,11 @@ class AuthController {
|
|
|
39
43
|
this.messages = Messages(this.languageCode);
|
|
40
44
|
|
|
41
45
|
this.loginButton = loginButton;
|
|
46
|
+
// Incremented by every new auth request, sign-out and re-initialization:
|
|
47
|
+
// an older request's poll then no longer changes the state.
|
|
48
|
+
this._authFlowId = 0;
|
|
49
|
+
/** @type {Object|null} signed-in state to return to if a switch does not complete */
|
|
50
|
+
this._switchPrevious = null;
|
|
42
51
|
|
|
43
52
|
function validateSettings (settings) {
|
|
44
53
|
if (!settings) { throw new Error('settings cannot be null'); }
|
|
@@ -55,6 +64,18 @@ class AuthController {
|
|
|
55
64
|
if (!settings.authRequest.requestedPermissions) {
|
|
56
65
|
throw new Error('Missing settings.authRequest.requestedPermissions');
|
|
57
66
|
}
|
|
67
|
+
|
|
68
|
+
// Delivery mode. Default to the one-time shared-secret hand-off so the
|
|
69
|
+
// token is never returned in the poll: a new core echoes
|
|
70
|
+
// `credentialHandoff` and delivers a `handoff` key (redeemed once here),
|
|
71
|
+
// an older core drops the field and falls back to inline delivery.
|
|
72
|
+
// Opt out with `authRequest.credentialHandoff = 'inline'`, which sends no
|
|
73
|
+
// field at all, so the request is byte-identical to the legacy one.
|
|
74
|
+
if (settings.authRequest.credentialHandoff === 'inline') {
|
|
75
|
+
delete settings.authRequest.credentialHandoff;
|
|
76
|
+
} else if (settings.authRequest.credentialHandoff == null) {
|
|
77
|
+
settings.authRequest.credentialHandoff = 'shared-secret';
|
|
78
|
+
}
|
|
58
79
|
}
|
|
59
80
|
}
|
|
60
81
|
|
|
@@ -63,6 +84,7 @@ class AuthController {
|
|
|
63
84
|
* @returns {Promise<Service>} Promise resolving to the Service instance
|
|
64
85
|
*/
|
|
65
86
|
async init () {
|
|
87
|
+
cancelAuthFlow(this);
|
|
66
88
|
this.serviceInfo = this.service.infoSync();
|
|
67
89
|
this.state = { status: AuthStates.LOADING };
|
|
68
90
|
this.assets = await loadAssets(this);
|
|
@@ -99,6 +121,8 @@ class AuthController {
|
|
|
99
121
|
* Stops poll for auth request
|
|
100
122
|
*/
|
|
101
123
|
stopAuthRequest (msg) {
|
|
124
|
+
// its poll must not change the state any more
|
|
125
|
+
cancelAuthFlow(this);
|
|
102
126
|
this.state = { status: AuthStates.ERROR, message: msg };
|
|
103
127
|
}
|
|
104
128
|
|
|
@@ -108,9 +132,22 @@ class AuthController {
|
|
|
108
132
|
*/
|
|
109
133
|
async handleClick () {
|
|
110
134
|
if (isAuthorized.call(this)) {
|
|
135
|
+
// A button with an account menu opens it (logout happens from there,
|
|
136
|
+
// through `signOut()`); otherwise the legacy SIGNOUT state lets the
|
|
137
|
+
// button confirm the logout itself.
|
|
138
|
+
const loginButton = this.loginButton;
|
|
139
|
+
if (loginButton != null && typeof loginButton.showMenu === 'function' &&
|
|
140
|
+
loginButton.showMenu() !== false) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
111
143
|
this.state = { status: AuthStates.SIGNOUT };
|
|
112
144
|
} else if (isInitialized.call(this)) {
|
|
113
145
|
this.startAuthRequest();
|
|
146
|
+
} else if (this.state.status === AuthStates.SWITCHING) {
|
|
147
|
+
// a switch is running; its outcome arrives as a state change
|
|
148
|
+
} else if (this.state.status === AuthStates.ERROR) {
|
|
149
|
+
// start over (stored sign-in or the sign-in button) rather than stay inert
|
|
150
|
+
await this.init();
|
|
114
151
|
} else if (isNeedSignIn.call(this)) {
|
|
115
152
|
// reopen popup (HACK for now: set to private property to avoid self-assignment)
|
|
116
153
|
this.state = this._state;
|
|
@@ -129,12 +166,198 @@ class AuthController {
|
|
|
129
166
|
}
|
|
130
167
|
}
|
|
131
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Log out: emit SIGNOUT once, forget the active account and return to
|
|
171
|
+
* INITIALIZED. The other remembered accounts are kept (`all` forgets them
|
|
172
|
+
* too). This is the confirmed logout (no further confirmation).
|
|
173
|
+
* @param {Object} [options]
|
|
174
|
+
* @param {boolean} [options.all] - forget every remembered account
|
|
175
|
+
* @returns {Promise<void>}
|
|
176
|
+
*/
|
|
177
|
+
async signOut (options) {
|
|
178
|
+
const all = options?.all === true;
|
|
179
|
+
cancelAuthFlow(this);
|
|
180
|
+
const store = this._readProfiles();
|
|
181
|
+
// during a switch, the account being left
|
|
182
|
+
const current = this.state?.username ?? this.state?.from;
|
|
183
|
+
this._signingOut = true;
|
|
184
|
+
try {
|
|
185
|
+
this.state = { status: AuthStates.SIGNOUT };
|
|
186
|
+
} finally {
|
|
187
|
+
this._signingOut = false;
|
|
188
|
+
}
|
|
189
|
+
// Drop any cached hand-off credential for this flow's key. Guard the
|
|
190
|
+
// key: on a cookie-autologin session there is no `_authFlowKey`, and
|
|
191
|
+
// clearing with `undefined` would wipe an unrelated concurrent flow.
|
|
192
|
+
if (this._authFlowKey != null) handoff.cacheClear(this._authFlowKey);
|
|
193
|
+
const others = current == null ? store.profiles : ProfileStore.remove(store, current).profiles;
|
|
194
|
+
const loginButton = this.loginButton;
|
|
195
|
+
if (!all && others.length > 0 && loginButton != null && typeof loginButton.saveAuthorizationData === 'function') {
|
|
196
|
+
loginButton.saveAuthorizationData(ProfileStore.write(Object.assign({}, store, { active: null, profiles: others })));
|
|
197
|
+
} else if (loginButton != null && typeof loginButton.deleteAuthorizationData === 'function') {
|
|
198
|
+
await loginButton.deleteAuthorizationData();
|
|
199
|
+
}
|
|
200
|
+
await this.init();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The accounts remembered for this app, most recently used first.
|
|
205
|
+
* @returns {Array<{username: string, actingAs?: {username: string, delegate: string}, active: boolean, available: boolean}>}
|
|
206
|
+
*/
|
|
207
|
+
profiles () {
|
|
208
|
+
const current = this.currentProfile();
|
|
209
|
+
return this._readProfiles().profiles.map((p) => {
|
|
210
|
+
const out = { username: p.username, active: current != null && current.username === p.username, available: p.unavailable !== true };
|
|
211
|
+
if (p.actingAs != null) out.actingAs = p.actingAs;
|
|
212
|
+
return out;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The signed-in account, or null.
|
|
218
|
+
* @returns {{username: string, actingAs?: {username: string, delegate: string}}|null}
|
|
219
|
+
*/
|
|
220
|
+
currentProfile () {
|
|
221
|
+
if (this.state?.status !== AuthStates.AUTHORIZED || this.state.profile == null) return null;
|
|
222
|
+
const out = { username: this.state.profile.username };
|
|
223
|
+
if (this.state.profile.actingAs != null) out.actingAs = this.state.profile.actingAs;
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Switch to another account. `username` null means the signed-in person's
|
|
229
|
+
* own account (switch back). A remembered account whose access is still
|
|
230
|
+
* valid is activated without a sign-in; otherwise the auth request runs
|
|
231
|
+
* again, asking for that account (`actAs`). Emits SWITCHING first; ends in
|
|
232
|
+
* AUTHORIZED, or back on the previous account when the sign-in is refused.
|
|
233
|
+
* @param {string|null} username
|
|
234
|
+
* @returns {Promise<void>}
|
|
235
|
+
*/
|
|
236
|
+
async switchTo (username) {
|
|
237
|
+
const store = this._readProfiles();
|
|
238
|
+
const current = this.currentProfile();
|
|
239
|
+
let target;
|
|
240
|
+
if (username == null) {
|
|
241
|
+
if (current != null && current.actingAs == null) return;
|
|
242
|
+
const delegate = current?.actingAs?.delegate;
|
|
243
|
+
// Acting for an account: back to the delegate's own account only, never
|
|
244
|
+
// to another remembered one. Not signed in: the most recent own account.
|
|
245
|
+
target = delegate != null
|
|
246
|
+
? store.profiles.find((p) => p.username === delegate && p.actingAs == null)
|
|
247
|
+
: store.profiles.find((p) => p.actingAs == null);
|
|
248
|
+
} else {
|
|
249
|
+
if (current != null && current.username === username) return;
|
|
250
|
+
target = store.profiles.find((p) => p.username === username);
|
|
251
|
+
}
|
|
252
|
+
const toUsername = target?.username ?? username ?? null;
|
|
253
|
+
const previous = restorableState(this.state);
|
|
254
|
+
// a log out or re-initialization during the access check ends this switch
|
|
255
|
+
cancelAuthFlow(this);
|
|
256
|
+
const flowId = this._authFlowId;
|
|
257
|
+
this.state = { status: AuthStates.SWITCHING, from: current?.username ?? null, to: toUsername };
|
|
258
|
+
|
|
259
|
+
if (target != null && target.unavailable !== true) {
|
|
260
|
+
let info;
|
|
261
|
+
try {
|
|
262
|
+
info = await this._accessInfo(target.apiEndpoint);
|
|
263
|
+
} catch (e) {
|
|
264
|
+
if (this._authFlowId !== flowId) throw e;
|
|
265
|
+
// network failure: stay on the previous account
|
|
266
|
+
this.state = previous ?? { status: AuthStates.INITIALIZED, serviceInfo: this.serviceInfo };
|
|
267
|
+
throw e;
|
|
268
|
+
}
|
|
269
|
+
if (this._authFlowId !== flowId) return;
|
|
270
|
+
if (info != null && info.error == null) {
|
|
271
|
+
const profile = profileFromAccessInfo(target, info);
|
|
272
|
+
this.state = { status: AuthStates.AUTHORIZED, username: profile.username, apiEndpoint: profile.apiEndpoint, profile };
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (!ACCESS_GONE_ERRORS.includes(info?.error?.id)) {
|
|
276
|
+
// any other answer (server error, rate limit) says nothing about the access
|
|
277
|
+
this.state = previous ?? { status: AuthStates.INITIALIZED, serviceInfo: this.serviceInfo };
|
|
278
|
+
throw new PryvError('Cannot check the access of ' + target.username + ': ' + (info?.error?.id ?? 'unexpected answer'), info?.error);
|
|
279
|
+
}
|
|
280
|
+
// revoked or expired (a detach revokes the accesses granted through it)
|
|
281
|
+
this._saveProfiles(ProfileStore.markUnavailable(this._readProfiles(), target.username));
|
|
282
|
+
}
|
|
283
|
+
// own account: a sign-in that offers no other account; otherwise ask for that one
|
|
284
|
+
await this.startAuthRequest({ actAs: username == null ? 'deny' : username }, previous);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Sign in to one more account (the popup may offer the accounts the person
|
|
289
|
+
* can act for); the remembered accounts are kept.
|
|
290
|
+
* @returns {Promise<void>}
|
|
291
|
+
*/
|
|
292
|
+
async addAccount () {
|
|
293
|
+
const current = this.currentProfile();
|
|
294
|
+
const previous = restorableState(this.state);
|
|
295
|
+
this.state = { status: AuthStates.SWITCHING, from: current?.username ?? null, to: null };
|
|
296
|
+
await this.startAuthRequest({ actAs: 'allow' }, previous);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* @private The access-info of a stored account (`{ error }` when refused).
|
|
301
|
+
* @param {string} apiEndpoint
|
|
302
|
+
*/
|
|
303
|
+
async _accessInfo (apiEndpoint) {
|
|
304
|
+
// required here: Connection is not needed before the first switch
|
|
305
|
+
const Connection = require('../Connection');
|
|
306
|
+
return await new Connection(apiEndpoint).accessInfo(true);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** @private */
|
|
310
|
+
_readProfiles () {
|
|
311
|
+
const loginButton = this.loginButton;
|
|
312
|
+
if (loginButton == null || typeof loginButton.getAuthorizationData !== 'function') return ProfileStore.read(null);
|
|
313
|
+
return ProfileStore.read(loginButton.getAuthorizationData());
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** @private */
|
|
317
|
+
_saveProfiles (store) {
|
|
318
|
+
const loginButton = this.loginButton;
|
|
319
|
+
if (loginButton == null || typeof loginButton.saveAuthorizationData !== 'function') return;
|
|
320
|
+
const data = ProfileStore.write(store);
|
|
321
|
+
if (data == null && typeof loginButton.deleteAuthorizationData === 'function') {
|
|
322
|
+
loginButton.deleteAuthorizationData();
|
|
323
|
+
} else if (data != null) {
|
|
324
|
+
loginButton.saveAuthorizationData(data);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* URL of the account app (profile page) for this platform, or null when
|
|
330
|
+
* it cannot be determined. Resolution order: `settings.accountUrl`, then
|
|
331
|
+
* the service's `account`, then the auth page URL of the last auth
|
|
332
|
+
* request with its trailing `/auth` removed.
|
|
333
|
+
* @returns {string|null}
|
|
334
|
+
*/
|
|
335
|
+
accountUrl () {
|
|
336
|
+
let base = this.settings.accountUrl || this.serviceInfo?.account || accountUrlFromAuthUrl(this._authUrl);
|
|
337
|
+
if (typeof base !== 'string' || base === '') return null;
|
|
338
|
+
base = base.replace(/\/+$/, '');
|
|
339
|
+
// @ts-ignore - Service keeps the URL it was created with
|
|
340
|
+
const serviceInfoUrl = this.service?._serviceInfoUrl;
|
|
341
|
+
return base + '/account/profile' +
|
|
342
|
+
(serviceInfoUrl ? '?pryvServiceInfoUrl=' + encodeURIComponent(serviceInfoUrl) : '');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Open the account app in a new tab.
|
|
347
|
+
* @returns {string|null} the URL opened, or null when unknown
|
|
348
|
+
*/
|
|
349
|
+
openAccountApp () {
|
|
350
|
+
const url = this.accountUrl();
|
|
351
|
+
if (url != null) window.open(url, '_blank', 'noopener');
|
|
352
|
+
return url;
|
|
353
|
+
}
|
|
354
|
+
|
|
132
355
|
/**
|
|
133
356
|
* Compute the return URL for authentication redirect.
|
|
134
357
|
* Used only in browser environments.
|
|
135
|
-
* @param {string} [returnURL] - The return URL setting ('auto#', 'self#', or custom URL)
|
|
358
|
+
* @param {string|false} [returnURL] - The return URL setting ('auto#', 'self#', or custom URL)
|
|
136
359
|
* @param {string} [windowLocationForTest] - Mock window.location.href for testing
|
|
137
|
-
* @param {string|Navigator} [navigatorForTests] - Mock navigator for testing
|
|
360
|
+
* @param {string|Navigator} [navigatorForTests] - Mock navigator for testing (defaults to the browser's own)
|
|
138
361
|
* @returns {string|boolean} The computed return URL, or false if using popup mode
|
|
139
362
|
*/
|
|
140
363
|
getReturnURL (
|
|
@@ -143,6 +366,7 @@ class AuthController {
|
|
|
143
366
|
navigatorForTests
|
|
144
367
|
) {
|
|
145
368
|
const RETURN_URL_AUTO = 'auto';
|
|
369
|
+
const nav = navigatorForTests ?? globalThis.navigator;
|
|
146
370
|
|
|
147
371
|
returnURL = returnURL || RETURN_URL_AUTO + '#';
|
|
148
372
|
|
|
@@ -154,11 +378,11 @@ class AuthController {
|
|
|
154
378
|
}
|
|
155
379
|
// auto mode for desktop
|
|
156
380
|
if (returnUrlIsAuto(returnURL) &&
|
|
157
|
-
!utils.browserIsMobileOrTablet(
|
|
381
|
+
!utils.browserIsMobileOrTablet(nav)) {
|
|
158
382
|
return false;
|
|
159
383
|
// auto mode for mobile or self
|
|
160
384
|
} else if ((returnUrlIsAuto(returnURL) &&
|
|
161
|
-
utils.browserIsMobileOrTablet(
|
|
385
|
+
utils.browserIsMobileOrTablet(nav)) ||
|
|
162
386
|
returnURL.indexOf('self') === 0) {
|
|
163
387
|
// set self as return url?
|
|
164
388
|
// eventually clean-up current url from previous pryv returnURL
|
|
@@ -174,16 +398,28 @@ class AuthController {
|
|
|
174
398
|
|
|
175
399
|
/**
|
|
176
400
|
* Start the authentication request and polling process
|
|
401
|
+
* @param {Object} [overrides] - auth request fields for this request only (e.g. `actAs`)
|
|
402
|
+
* @param {Object} [previous] - AUTHORIZED state to return to when this
|
|
403
|
+
* request (an account switch) does not end in AUTHORIZED
|
|
177
404
|
* @returns {Promise<void>}
|
|
178
405
|
* @see https://pryv.github.io/reference/#auth-request
|
|
179
406
|
*/
|
|
180
|
-
async startAuthRequest () {
|
|
407
|
+
async startAuthRequest (overrides, previous) {
|
|
408
|
+
cancelAuthFlow(this);
|
|
409
|
+
const flowId = this._authFlowId;
|
|
410
|
+
this._switchPrevious = previous ?? null;
|
|
181
411
|
// @ts-ignore - postAccess uses .call(this) for context
|
|
182
|
-
|
|
412
|
+
const requested = await postAccess.call(this);
|
|
413
|
+
if (this._authFlowId !== flowId) return; // replaced while posting
|
|
414
|
+
this.state = requested;
|
|
183
415
|
// Remember the polling key so listeners on the terminal AUTHORIZED
|
|
184
416
|
// state can be handed `{ key, serviceInfo? }` (the polling response
|
|
185
417
|
// itself doesn't echo `key` back).
|
|
186
418
|
this._authFlowKey = this.state?.key;
|
|
419
|
+
// an app's later connectFromKey(key) then polls the core holding the request
|
|
420
|
+
pollUrls.remember(this.state?.key, this.state?.poll);
|
|
421
|
+
// Kept to locate the account app when the service does not name it.
|
|
422
|
+
if (this.state?.authUrl) this._authUrl = this.state.authUrl;
|
|
187
423
|
|
|
188
424
|
await doPolling.call(this);
|
|
189
425
|
|
|
@@ -194,14 +430,19 @@ class AuthController {
|
|
|
194
430
|
// @ts-ignore - this is bound via .call()
|
|
195
431
|
this.serviceInfo.access,
|
|
196
432
|
// @ts-ignore - this is bound via .call()
|
|
197
|
-
this.settings.authRequest
|
|
433
|
+
Object.assign({}, this.settings.authRequest, overrides)
|
|
198
434
|
);
|
|
199
435
|
if (!response.ok) {
|
|
200
|
-
|
|
436
|
+
// The server's message, id and status; the body stays on `response`,
|
|
437
|
+
// never in the message (it echoes the request's permissions and data).
|
|
438
|
+
throw PryvError.fromApiResponse(response, body);
|
|
201
439
|
}
|
|
202
440
|
return body;
|
|
203
441
|
} catch (e) {
|
|
204
|
-
this.
|
|
442
|
+
if (this._authFlowId !== flowId) throw e; // replaced while posting
|
|
443
|
+
const previous = this._switchPrevious;
|
|
444
|
+
this._switchPrevious = null;
|
|
445
|
+
this.state = previous ?? {
|
|
205
446
|
status: AuthStates.ERROR,
|
|
206
447
|
message: 'Requesting access',
|
|
207
448
|
error: e
|
|
@@ -213,21 +454,62 @@ class AuthController {
|
|
|
213
454
|
/** @this {AuthController} */
|
|
214
455
|
async function doPolling () {
|
|
215
456
|
// @ts-ignore - this is bound via .call()
|
|
216
|
-
if (this.state?.status !== AuthStates.NEED_SIGNIN) {
|
|
457
|
+
if (this._authFlowId !== flowId || this.state?.status !== AuthStates.NEED_SIGNIN) {
|
|
217
458
|
return;
|
|
218
459
|
}
|
|
219
460
|
// @ts-ignore - this is bound via .call()
|
|
220
461
|
const pollResponse = await pollAccess(this.state?.poll);
|
|
462
|
+
// a newer request, a sign-out or a re-initialization replaced this one
|
|
463
|
+
// @ts-ignore - this is bound via .call()
|
|
464
|
+
if (this._authFlowId !== flowId) return;
|
|
221
465
|
|
|
222
466
|
if (pollResponse.status === AuthStates.NEED_SIGNIN) {
|
|
223
467
|
// @ts-ignore - this is bound via .call()
|
|
224
468
|
setTimeout(await doPolling.bind(this), this.state?.poll_rate_ms);
|
|
225
469
|
} else {
|
|
470
|
+
// Shared-secret delivery: the ACCEPTED body carries a one-time
|
|
471
|
+
// `handoff` key, not the token. Redeem it once here (caching under the
|
|
472
|
+
// poll key so a later connectFromKey reuses it) and rewrite the body to
|
|
473
|
+
// the legacy shape, so the cookie / LoginButton path and the external
|
|
474
|
+
// listener filter are untouched.
|
|
475
|
+
if (handoff.isHandoffBody(pollResponse)) {
|
|
476
|
+
try {
|
|
477
|
+
const entry = await handoff.resolveHandoff(pollResponse, this._authFlowKey);
|
|
478
|
+
pollResponse.apiEndpoint = entry.apiEndpoint;
|
|
479
|
+
pollResponse.token = entry.token;
|
|
480
|
+
pollResponse.username = entry.username;
|
|
481
|
+
delete pollResponse.handoff;
|
|
482
|
+
} catch (e) {
|
|
483
|
+
// @ts-ignore - this is bound via .call()
|
|
484
|
+
if (this._authFlowId !== flowId) return;
|
|
485
|
+
// @ts-ignore - this is bound via .call()
|
|
486
|
+
const previous = this._switchPrevious;
|
|
487
|
+
// @ts-ignore - this is bound via .call()
|
|
488
|
+
this._switchPrevious = null;
|
|
489
|
+
if (previous != null) console.warn('pryv: account switch did not complete (credential hand-off failed); keeping the previous account');
|
|
490
|
+
this.state = previous ?? { status: AuthStates.ERROR, message: 'Credential hand-off failed', error: e };
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
// a newer request, a sign-out or a re-initialization replaced this one
|
|
494
|
+
// @ts-ignore - this is bound via .call()
|
|
495
|
+
if (this._authFlowId !== flowId) return;
|
|
496
|
+
}
|
|
226
497
|
// Carry the key forward — listeners on the narrow public surface
|
|
227
498
|
// need it, and the server doesn't echo it back on ACCEPTED.
|
|
228
499
|
if (this._authFlowKey != null && pollResponse.key == null) {
|
|
229
500
|
pollResponse.key = this._authFlowKey;
|
|
230
501
|
}
|
|
502
|
+
const previous = this._switchPrevious;
|
|
503
|
+
this._switchPrevious = null;
|
|
504
|
+
if (pollResponse.status === AuthStates.AUTHORIZED) {
|
|
505
|
+
pollResponse.profile = ProfileStore.fromAccepted(pollResponse);
|
|
506
|
+
} else if (previous != null) {
|
|
507
|
+
// an account switch that did not complete: stay on the previous account
|
|
508
|
+
console.warn('pryv: account switch did not complete (' +
|
|
509
|
+
(pollResponse?.error?.id ?? pollResponse?.message ?? pollResponse?.status) + '); keeping the previous account');
|
|
510
|
+
this.state = previous;
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
231
513
|
this.state = pollResponse;
|
|
232
514
|
}
|
|
233
515
|
|
|
@@ -307,16 +589,87 @@ function filterForExternalListener (state) {
|
|
|
307
589
|
return out;
|
|
308
590
|
}
|
|
309
591
|
|
|
592
|
+
/**
|
|
593
|
+
* The account app is served next to the auth page: strip a trailing `/auth`
|
|
594
|
+
* path segment (and the query) from the auth page URL. Null when the URL
|
|
595
|
+
* does not have that shape.
|
|
596
|
+
* @param {string} [authUrl]
|
|
597
|
+
* @returns {string|null}
|
|
598
|
+
*/
|
|
599
|
+
function accountUrlFromAuthUrl (authUrl) {
|
|
600
|
+
if (typeof authUrl !== 'string') return null;
|
|
601
|
+
let url;
|
|
602
|
+
try { url = new URL(authUrl); } catch (e) { return null; }
|
|
603
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
604
|
+
if (!path.endsWith('/auth')) return null;
|
|
605
|
+
return url.origin + path.slice(0, -'/auth'.length);
|
|
606
|
+
}
|
|
607
|
+
|
|
310
608
|
async function checkAutoLogin (authController) {
|
|
311
609
|
const loginButton = authController.loginButton;
|
|
312
610
|
if (loginButton == null) {
|
|
313
611
|
return;
|
|
314
612
|
}
|
|
315
613
|
|
|
316
|
-
|
|
317
|
-
if (storedCredentials
|
|
318
|
-
|
|
614
|
+
let storedCredentials = await loginButton.getAuthorizationData();
|
|
615
|
+
if (storedCredentials == null) return;
|
|
616
|
+
// Forget the stored sign-ins that carry no token (3.13.0 saved some after a
|
|
617
|
+
// redirect return): they would sign in to a session the API refuses, or
|
|
618
|
+
// stay listed as available accounts.
|
|
619
|
+
const stored = ProfileStore.read(storedCredentials);
|
|
620
|
+
const tokenless = stored.profiles.filter((p) => !ProfileStore.carriesToken(p.apiEndpoint));
|
|
621
|
+
if (tokenless.length > 0) {
|
|
622
|
+
authController._saveProfiles(tokenless.reduce((s, p) => ProfileStore.remove(s, p.username), stored));
|
|
623
|
+
storedCredentials = await loginButton.getAuthorizationData();
|
|
624
|
+
if (storedCredentials == null) return;
|
|
625
|
+
}
|
|
626
|
+
if (typeof storedCredentials.authUrl === 'string') authController._authUrl = storedCredentials.authUrl;
|
|
627
|
+
if (Array.isArray(storedCredentials.profiles)) {
|
|
628
|
+
// Several remembered accounts: sign in to the active one, if any
|
|
629
|
+
const store = ProfileStore.read(storedCredentials);
|
|
630
|
+
if (store.active == null || !ProfileStore.carriesToken(store.active.apiEndpoint)) return;
|
|
631
|
+
const state = { status: AuthStates.AUTHORIZED, username: store.active.username, apiEndpoint: store.active.apiEndpoint, profile: store.active };
|
|
632
|
+
if (store.authUrl != null) state.authUrl = store.authUrl;
|
|
633
|
+
authController.state = state;
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
// a button that cannot rewrite its storage still must not sign in to it
|
|
637
|
+
if (typeof storedCredentials.apiEndpoint === 'string' && !ProfileStore.carriesToken(storedCredentials.apiEndpoint)) return;
|
|
638
|
+
const state = Object.assign({}, { status: AuthStates.AUTHORIZED }, storedCredentials);
|
|
639
|
+
if (typeof state.username === 'string' && typeof state.apiEndpoint === 'string') state.profile = ProfileStore.profileOf(state);
|
|
640
|
+
authController.state = state;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** A stored profile refreshed with what its access says about itself. */
|
|
644
|
+
function profileFromAccessInfo (stored, info) {
|
|
645
|
+
const profile = { username: stored.username, apiEndpoint: stored.apiEndpoint };
|
|
646
|
+
const d = info.delegation;
|
|
647
|
+
if (d != null && d.isDelegatedAccess === true && typeof d.delegate?.username === 'string') {
|
|
648
|
+
profile.actingAs = { username: stored.username, delegate: d.delegate.username };
|
|
319
649
|
}
|
|
650
|
+
return profile;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** API errors that mean a stored access is no longer usable. */
|
|
654
|
+
const ACCESS_GONE_ERRORS = ['invalid-access-token', 'forbidden'];
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* The signed-in state to return to when an account switch does not
|
|
658
|
+
* complete: the account as stored, without the `key` of its sign-in (that
|
|
659
|
+
* auth request is consumed), like a sign-in from stored credentials.
|
|
660
|
+
*/
|
|
661
|
+
function restorableState (state) {
|
|
662
|
+
if (state?.status !== AuthStates.AUTHORIZED) return null;
|
|
663
|
+
const restored = { status: AuthStates.AUTHORIZED, username: state.username, apiEndpoint: state.apiEndpoint };
|
|
664
|
+
if (state.profile != null) restored.profile = state.profile;
|
|
665
|
+
if (state.authUrl != null) restored.authUrl = state.authUrl;
|
|
666
|
+
return restored;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Stop any auth request in progress: its poll no longer changes the state. */
|
|
670
|
+
function cancelAuthFlow (authController) {
|
|
671
|
+
authController._authFlowId = (authController._authFlowId || 0) + 1;
|
|
672
|
+
authController._switchPrevious = null;
|
|
320
673
|
}
|
|
321
674
|
|
|
322
675
|
// ------------------ 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
|
};
|