pryv 3.12.0 → 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 +341 -12
- 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 -14
- package/src/Service.js +28 -3
- 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/Browser.AuthController.test.js +4 -3
- package/test/LoginButton.test.js +583 -4
- package/test/LoginButton.urlCleanup.test.js +15 -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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The accounts a sign-in button remembers for one app, as stored in its
|
|
8
|
+
* authorization data:
|
|
9
|
+
* `{ apiEndpoint, username, actingAs?, authUrl?, profiles: [...] }`.
|
|
10
|
+
* The top-level `apiEndpoint` / `username` are the active account (what
|
|
11
|
+
* versions without profiles read); `profiles` lists every remembered
|
|
12
|
+
* account, most recently used first, the active one included.
|
|
13
|
+
* A profile is `{ username, apiEndpoint, actingAs?, unavailable? }`, where
|
|
14
|
+
* `actingAs = { username, delegate }` marks an account used through account
|
|
15
|
+
* delegation (`delegate` is the username of the person acting).
|
|
16
|
+
* @memberof pryv.Auth
|
|
17
|
+
*/
|
|
18
|
+
module.exports = {
|
|
19
|
+
DEFAULT_LIMIT: 5,
|
|
20
|
+
read,
|
|
21
|
+
write,
|
|
22
|
+
activate,
|
|
23
|
+
remove,
|
|
24
|
+
markUnavailable,
|
|
25
|
+
profileOf,
|
|
26
|
+
fromAccepted
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse stored authorization data (any version) into
|
|
31
|
+
* `{ active, profiles, authUrl }`; `active` is `profiles[0]` or null.
|
|
32
|
+
* @param {Object|null|undefined} stored
|
|
33
|
+
*/
|
|
34
|
+
function read (stored) {
|
|
35
|
+
const store = { active: null, profiles: [], authUrl: undefined };
|
|
36
|
+
if (stored == null || typeof stored !== 'object' || stored.deleted === true) return store;
|
|
37
|
+
if (typeof stored.authUrl === 'string') store.authUrl = stored.authUrl;
|
|
38
|
+
if (Array.isArray(stored.profiles)) {
|
|
39
|
+
store.profiles = stored.profiles.filter(isProfile).map(profileOf);
|
|
40
|
+
}
|
|
41
|
+
if (isProfile(stored)) {
|
|
42
|
+
const active = profileOf(stored);
|
|
43
|
+
store.profiles = [active].concat(store.profiles.filter((p) => p.username !== active.username));
|
|
44
|
+
store.active = active;
|
|
45
|
+
}
|
|
46
|
+
return store;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The authorization data to store for `store`.
|
|
51
|
+
* @returns {Object|null} null when nothing is left to remember
|
|
52
|
+
*/
|
|
53
|
+
function write (store) {
|
|
54
|
+
if (store.profiles.length === 0) return null;
|
|
55
|
+
const data = {};
|
|
56
|
+
if (store.active != null) {
|
|
57
|
+
data.apiEndpoint = store.active.apiEndpoint;
|
|
58
|
+
data.username = store.active.username;
|
|
59
|
+
if (store.active.actingAs != null) data.actingAs = store.active.actingAs;
|
|
60
|
+
}
|
|
61
|
+
if (store.authUrl != null) data.authUrl = store.authUrl;
|
|
62
|
+
data.profiles = store.profiles.map(profileOf);
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Make `profile` the active account (replacing a stored one with the same
|
|
68
|
+
* username), then forget the least recently used accounts beyond `limit`.
|
|
69
|
+
*/
|
|
70
|
+
function activate (store, profile, limit) {
|
|
71
|
+
const max = Math.max(1, limit || module.exports.DEFAULT_LIMIT);
|
|
72
|
+
const active = profileOf(profile);
|
|
73
|
+
const profiles = [active].concat(store.profiles.filter((p) => p.username !== active.username));
|
|
74
|
+
return Object.assign({}, store, { active, profiles: profiles.slice(0, max) });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Forget `username`; forgetting the active account leaves none active. */
|
|
78
|
+
function remove (store, username) {
|
|
79
|
+
const profiles = store.profiles.filter((p) => p.username !== username);
|
|
80
|
+
const active = (store.active != null && store.active.username !== username) ? store.active : null;
|
|
81
|
+
return Object.assign({}, store, { active, profiles });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Keep `username` listed, marked as no longer usable (revoked, detached). */
|
|
85
|
+
function markUnavailable (store, username) {
|
|
86
|
+
const mark = (p) => (p.username === username ? Object.assign(profileOf(p), { unavailable: true }) : p);
|
|
87
|
+
const profiles = store.profiles.map(mark);
|
|
88
|
+
const active = (store.active != null && store.active.username === username) ? null : store.active;
|
|
89
|
+
return Object.assign({}, store, { active, profiles });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The stored fields of a profile, nothing else (credentials included). */
|
|
93
|
+
function profileOf (p) {
|
|
94
|
+
const profile = { username: p.username, apiEndpoint: p.apiEndpoint };
|
|
95
|
+
if (p.actingAs != null && typeof p.actingAs.delegate === 'string') {
|
|
96
|
+
profile.actingAs = { username: p.username, delegate: p.actingAs.delegate };
|
|
97
|
+
}
|
|
98
|
+
if (p.unavailable === true) profile.unavailable = true;
|
|
99
|
+
return profile;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isProfile (p) {
|
|
103
|
+
return p != null && typeof p.username === 'string' && typeof p.apiEndpoint === 'string';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The profile of an ACCEPTED auth-request body. Its `delegation` block is a
|
|
108
|
+
* display hint posted by the auth page; `accessInfo().delegation` is
|
|
109
|
+
* authoritative and replaces it when the profile is next activated.
|
|
110
|
+
*/
|
|
111
|
+
function fromAccepted (body) {
|
|
112
|
+
const profile = { username: body.username, apiEndpoint: body.apiEndpoint };
|
|
113
|
+
const d = body.delegation;
|
|
114
|
+
if (d != null && d.isDelegatedAccess === true && d.controlledUsername === body.username &&
|
|
115
|
+
typeof d.delegate?.username === 'string') {
|
|
116
|
+
profile.actingAs = { username: body.username, delegate: d.delegate.username };
|
|
117
|
+
}
|
|
118
|
+
return profile;
|
|
119
|
+
}
|
|
@@ -5,10 +5,16 @@
|
|
|
5
5
|
const Cookies = require('./CookieUtils');
|
|
6
6
|
const AuthStates = require('../Auth/AuthStates');
|
|
7
7
|
const AuthController = require('../Auth/AuthController');
|
|
8
|
+
const ProfileStore = require('../Auth/ProfileStore');
|
|
8
9
|
const Messages = require('../Auth/LoginMessages');
|
|
9
10
|
const utils = require('../utils');
|
|
10
11
|
|
|
11
|
-
/* global location
|
|
12
|
+
/* global location */
|
|
13
|
+
|
|
14
|
+
/** Suffix of the cookie that holds the remembered accounts. */
|
|
15
|
+
const PROFILES_COOKIE_SUFFIX = '-profiles';
|
|
16
|
+
/** Encoded length kept under the ~4096-byte cookie limit (name and attributes included). */
|
|
17
|
+
const PROFILES_COOKIE_MAX_LENGTH = 3500;
|
|
12
18
|
|
|
13
19
|
/**
|
|
14
20
|
* @memberof pryv.Browser
|
|
@@ -64,19 +70,29 @@ class LoginButton {
|
|
|
64
70
|
}
|
|
65
71
|
break;
|
|
66
72
|
}
|
|
67
|
-
case AuthStates.AUTHORIZED:
|
|
68
|
-
|
|
69
|
-
this.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
case AuthStates.AUTHORIZED: {
|
|
74
|
+
const profile = state.profile || ProfileStore.fromAccepted(state);
|
|
75
|
+
this.text = profileLabel(this, profile);
|
|
76
|
+
const store = ProfileStore.read(this.getAuthorizationData());
|
|
77
|
+
// Kept to locate the account app after a reload (see AuthController.accountUrl).
|
|
78
|
+
const authUrl = withoutQuery(state.authUrl || this.auth?._authUrl) || store.authUrl;
|
|
79
|
+
this.saveAuthorizationData(ProfileStore.write(Object.assign(
|
|
80
|
+
ProfileStore.activate(store, profile, this.authSettings.maxProfiles),
|
|
81
|
+
{ authUrl }
|
|
82
|
+
)));
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case AuthStates.SWITCHING:
|
|
86
|
+
this.text = this.messages.SWITCHING || '...';
|
|
73
87
|
break;
|
|
74
88
|
case AuthStates.SIGNOUT: {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
89
|
+
// A confirmed logout (the menu's "Log out", or `auth.signOut()`)
|
|
90
|
+
// clears the credentials itself.
|
|
91
|
+
if (this.auth?._signingOut) break;
|
|
92
|
+
// Menu disabled (`settings.menu: false`): confirm in a small built-in
|
|
93
|
+
// dialog, then clear the credentials (see buildMenu: the re-init is
|
|
94
|
+
// awaited there, never left running in the background).
|
|
95
|
+
this.openMenu({ confirmLogout: true });
|
|
80
96
|
break;
|
|
81
97
|
}
|
|
82
98
|
case AuthStates.ERROR:
|
|
@@ -92,16 +108,104 @@ class LoginButton {
|
|
|
92
108
|
}
|
|
93
109
|
}
|
|
94
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Open the account menu (signed-in account, "Manage my account",
|
|
113
|
+
* "Log out"). Returns false, and opens nothing, when the menu is disabled
|
|
114
|
+
* with `settings.menu: false`: the caller then falls back to the logout
|
|
115
|
+
* confirmation.
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
showMenu () {
|
|
119
|
+
if (this.authSettings.menu === false) return false;
|
|
120
|
+
return this.openMenu({ confirmLogout: false });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @private Open the account menu, or (`confirmLogout`) the plain "Log out?"
|
|
125
|
+
* confirmation used when the menu is disabled.
|
|
126
|
+
*/
|
|
127
|
+
openMenu ({ confirmLogout }) {
|
|
128
|
+
if (typeof document === 'undefined') return false;
|
|
129
|
+
this.closeMenu();
|
|
130
|
+
this.menu = buildMenu(this, confirmLogout);
|
|
131
|
+
document.body.appendChild(this.menu.overlay);
|
|
132
|
+
this.menu.focusables()[0]?.focus();
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Close the account menu if it is open. Dismissing the "Log out?" question
|
|
138
|
+
* (anything but "Log out") returns to the signed-in state: SIGNOUT was
|
|
139
|
+
* already emitted by the click, and the button would otherwise stay inert.
|
|
140
|
+
* @param {boolean} [loggingOut] - closed by the "Log out" action
|
|
141
|
+
* @returns {Promise<void>} resolves when any re-init it started is done
|
|
142
|
+
*/
|
|
143
|
+
closeMenu (loggingOut) {
|
|
144
|
+
if (this.menu == null) return Promise.resolve();
|
|
145
|
+
const menu = this.menu;
|
|
146
|
+
this.menu = null;
|
|
147
|
+
document.removeEventListener('keydown', menu.onKeyDown, true);
|
|
148
|
+
if (menu.overlay.parentNode != null) menu.overlay.parentNode.removeChild(menu.overlay);
|
|
149
|
+
const previousFocus = /** @type {HTMLElement|null} */ (menu.previousFocus);
|
|
150
|
+
if (previousFocus != null && typeof previousFocus.focus === 'function') {
|
|
151
|
+
previousFocus.focus();
|
|
152
|
+
}
|
|
153
|
+
if (menu.confirmLogout && !loggingOut) {
|
|
154
|
+
// init() reports its own failures as the ERROR state
|
|
155
|
+
this.pending = this.auth.init().then(() => {}, (e) => { console.log('Error while re-initializing', e); });
|
|
156
|
+
return this.pending;
|
|
157
|
+
}
|
|
158
|
+
return Promise.resolve();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The stored sign-in: the active account from the main cookie (the only
|
|
163
|
+
* cookie versions without account switching read) and the remembered
|
|
164
|
+
* accounts from a second one.
|
|
165
|
+
*/
|
|
95
166
|
getAuthorizationData () {
|
|
96
|
-
|
|
167
|
+
const active = Cookies.get(this._cookieKey);
|
|
168
|
+
const remembered = Cookies.get(this._cookieKey + PROFILES_COOKIE_SUFFIX);
|
|
169
|
+
if (remembered == null || !Array.isArray(remembered.profiles)) return active;
|
|
170
|
+
const data = Object.assign({}, active != null && active.deleted !== true ? active : {}, { profiles: remembered.profiles });
|
|
171
|
+
if (data.authUrl == null && remembered.authUrl != null) data.authUrl = remembered.authUrl;
|
|
172
|
+
return data;
|
|
97
173
|
}
|
|
98
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Store the sign-in. The main cookie holds the active account only (or is
|
|
177
|
+
* removed when none is active), so an older version never reads a list of
|
|
178
|
+
* remembered accounts as a signed-in one.
|
|
179
|
+
*/
|
|
99
180
|
saveAuthorizationData (authData) {
|
|
100
|
-
|
|
181
|
+
if (authData == null) {
|
|
182
|
+
Cookies.del(this._cookieKey);
|
|
183
|
+
Cookies.del(this._cookieKey + PROFILES_COOKIE_SUFFIX);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const { profiles, ...active } = authData;
|
|
187
|
+
if (typeof active.username === 'string' && typeof active.apiEndpoint === 'string') {
|
|
188
|
+
Cookies.set(this._cookieKey, active);
|
|
189
|
+
} else {
|
|
190
|
+
Cookies.del(this._cookieKey);
|
|
191
|
+
}
|
|
192
|
+
if (Array.isArray(profiles)) {
|
|
193
|
+
const remembered = Object.assign({ profiles: profiles.slice() }, active.authUrl != null ? { authUrl: active.authUrl } : {});
|
|
194
|
+
// A browser drops a cookie over ~4 KB: forget the least recently used
|
|
195
|
+
// accounts (the list is most recent first) until it fits.
|
|
196
|
+
while (remembered.profiles.length > 1 &&
|
|
197
|
+
encodeURIComponent(JSON.stringify(remembered)).length > PROFILES_COOKIE_MAX_LENGTH) {
|
|
198
|
+
remembered.profiles.pop();
|
|
199
|
+
}
|
|
200
|
+
Cookies.set(this._cookieKey + PROFILES_COOKIE_SUFFIX, remembered);
|
|
201
|
+
} else {
|
|
202
|
+
Cookies.del(this._cookieKey + PROFILES_COOKIE_SUFFIX);
|
|
203
|
+
}
|
|
101
204
|
}
|
|
102
205
|
|
|
103
206
|
async deleteAuthorizationData () {
|
|
104
207
|
Cookies.del(this._cookieKey);
|
|
208
|
+
Cookies.del(this._cookieKey + PROFILES_COOKIE_SUFFIX);
|
|
105
209
|
}
|
|
106
210
|
|
|
107
211
|
/**
|
|
@@ -119,6 +223,7 @@ class LoginButton {
|
|
|
119
223
|
if (pollUrl !== null) {
|
|
120
224
|
try {
|
|
121
225
|
const { body } = await utils.fetchGet(pollUrl);
|
|
226
|
+
if (body?.status === AuthStates.AUTHORIZED && typeof body.username === 'string') body.profile = ProfileStore.fromAccepted(body);
|
|
122
227
|
authController.state = body;
|
|
123
228
|
} catch (e) {
|
|
124
229
|
authController.state = {
|
|
@@ -182,6 +287,270 @@ async function startLoginScreen (loginButton, authUrl) {
|
|
|
182
287
|
}
|
|
183
288
|
}
|
|
184
289
|
|
|
290
|
+
const MENU_OPTIONS = ['logout', 'account', 'switch', 'info'];
|
|
291
|
+
|
|
292
|
+
/** `kim-doe (via parent-doe)` for an account used through delegation. */
|
|
293
|
+
function profileLabel (loginButton, profile) {
|
|
294
|
+
if (profile?.actingAs == null) return profile?.username;
|
|
295
|
+
return profile.username + ' (' + loginButton.messages.VIA + ' ' + profile.actingAs.delegate + ')';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The auth page URL without its query and fragment (they may carry a key). */
|
|
299
|
+
function withoutQuery (url) {
|
|
300
|
+
if (typeof url !== 'string') return undefined;
|
|
301
|
+
try {
|
|
302
|
+
const u = new URL(url);
|
|
303
|
+
return u.origin + u.pathname;
|
|
304
|
+
} catch (e) {
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Which menu entries to show: all by default; `settings.menu.<option>: false`
|
|
311
|
+
* or `settings.menu.hide: ['<option>', ...]` hides one.
|
|
312
|
+
*/
|
|
313
|
+
function menuOptions (menuSettings) {
|
|
314
|
+
const options = {};
|
|
315
|
+
MENU_OPTIONS.forEach((o) => { options[o] = true; });
|
|
316
|
+
if (menuSettings != null && typeof menuSettings === 'object') {
|
|
317
|
+
MENU_OPTIONS.forEach((o) => { if (menuSettings[o] === false) options[o] = false; });
|
|
318
|
+
if (Array.isArray(menuSettings.hide)) {
|
|
319
|
+
menuSettings.hide.forEach((o) => { if (o in options) options[o] = false; });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return options;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const MENU_CSS = `
|
|
326
|
+
.pryv-menu-overlay { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 2147483000; display: flex;
|
|
327
|
+
align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.35); }
|
|
328
|
+
.pryv-menu { background: #fff; color: #222; min-width: 280px; max-width: 90vw; border-radius: 8px;
|
|
329
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); font: 14px/1.4 system-ui, sans-serif; }
|
|
330
|
+
.pryv-menu-header { display: flex; align-items: flex-start; justify-content: space-between;
|
|
331
|
+
gap: 16px; padding: 16px 16px 8px; }
|
|
332
|
+
.pryv-menu-username { font-weight: 600; font-size: 16px; overflow-wrap: anywhere; }
|
|
333
|
+
.pryv-menu-info { color: #666; font-size: 13px; padding: 0 16px 12px; overflow-wrap: anywhere; }
|
|
334
|
+
.pryv-menu-close { border: 0; background: none; font-size: 20px; line-height: 1; cursor: pointer; color: #666; }
|
|
335
|
+
.pryv-menu-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;
|
|
336
|
+
padding: 12px 16px 16px; border-top: 1px solid #eee; }
|
|
337
|
+
.pryv-menu-actions button { padding: 6px 12px; border-radius: 4px; border: 1px solid #ccc;
|
|
338
|
+
background: #f7f7f7; cursor: pointer; font: inherit; }
|
|
339
|
+
.pryv-menu-actions button:focus-visible, .pryv-menu-close:focus-visible,
|
|
340
|
+
.pryv-menu-switch button:focus-visible { outline: 2px solid #4a90d9; }
|
|
341
|
+
.pryv-menu-acting { font-weight: 400; color: #666; font-size: 13px; }
|
|
342
|
+
.pryv-menu-switch { padding: 8px 16px 12px; border-top: 1px solid #eee; }
|
|
343
|
+
.pryv-menu-switch-title { color: #666; font-size: 13px; margin-bottom: 4px; }
|
|
344
|
+
.pryv-menu-switch button { display: block; width: 100%; text-align: left; padding: 6px 8px; border: 0;
|
|
345
|
+
border-radius: 4px; background: none; cursor: pointer; font: inherit; color: inherit; }
|
|
346
|
+
.pryv-menu-switch button:hover { background: #f2f2f2; }
|
|
347
|
+
.pryv-menu-switch button[aria-current="true"] { font-weight: 600; cursor: default; }
|
|
348
|
+
.pryv-menu-switch .pryv-menu-note { color: #888; font-size: 12px; }
|
|
349
|
+
`;
|
|
350
|
+
|
|
351
|
+
/** The built-in menu style, added once. A service's button CSS can override
|
|
352
|
+
* the `.pryv-menu*` classes. */
|
|
353
|
+
function ensureMenuStyle () {
|
|
354
|
+
if (document.getElementById('pryv-menu-style') != null) return;
|
|
355
|
+
const style = document.createElement('style');
|
|
356
|
+
style.id = 'pryv-menu-style';
|
|
357
|
+
style.textContent = MENU_CSS;
|
|
358
|
+
// First in <head>: a service stylesheet loaded before or after the menu
|
|
359
|
+
// opens then wins at equal specificity.
|
|
360
|
+
document.head.insertBefore(style, document.head.firstChild);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function menuElement (tag, className, text) {
|
|
364
|
+
const el = document.createElement(tag);
|
|
365
|
+
if (className) el.className = className;
|
|
366
|
+
if (text != null) el.textContent = text;
|
|
367
|
+
return el;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Build the account menu: a modal dialog, closed by its close button,
|
|
372
|
+
* Escape, or a click outside it, with focus kept inside while open.
|
|
373
|
+
* `confirmLogout`: the same dialog reduced to the "Log out?" question, for
|
|
374
|
+
* `settings.menu: false` (SIGNOUT was already emitted by the click).
|
|
375
|
+
*/
|
|
376
|
+
function buildMenu (loginBtn, confirmLogout) {
|
|
377
|
+
ensureMenuStyle();
|
|
378
|
+
const auth = loginBtn.auth;
|
|
379
|
+
const messages = Object.assign({}, loginBtn.messages, auth.messages);
|
|
380
|
+
const options = confirmLogout
|
|
381
|
+
? { logout: true, account: false, info: false }
|
|
382
|
+
: menuOptions(loginBtn.authSettings.menu);
|
|
383
|
+
const username = confirmLogout
|
|
384
|
+
? (messages.SIGNOUT_CONFIRM || 'Logout?')
|
|
385
|
+
: (auth.state?.username || '');
|
|
386
|
+
|
|
387
|
+
const overlay = menuElement('div', 'pryv-menu-overlay');
|
|
388
|
+
const dialog = menuElement('div', 'pryv-menu');
|
|
389
|
+
// A question that needs an answer is an alertdialog; the menu is a dialog.
|
|
390
|
+
dialog.setAttribute('role', confirmLogout ? 'alertdialog' : 'dialog');
|
|
391
|
+
dialog.setAttribute('aria-modal', 'true');
|
|
392
|
+
dialog.setAttribute('aria-labelledby', 'pryv-menu-username');
|
|
393
|
+
overlay.appendChild(dialog);
|
|
394
|
+
|
|
395
|
+
const header = menuElement('div', 'pryv-menu-header');
|
|
396
|
+
const title = menuElement('div', 'pryv-menu-username', username || messages.MENU_TITLE);
|
|
397
|
+
title.id = 'pryv-menu-username';
|
|
398
|
+
const current = confirmLogout ? null : auth.currentProfile();
|
|
399
|
+
const actingAs = current?.actingAs;
|
|
400
|
+
if (actingAs != null) {
|
|
401
|
+
title.appendChild(menuElement('span', 'pryv-menu-acting',
|
|
402
|
+
' (' + messages.ACTING_AS + ', ' + messages.VIA + ' ' + actingAs.delegate + ')'));
|
|
403
|
+
}
|
|
404
|
+
header.appendChild(title);
|
|
405
|
+
const close = menuElement('button', 'pryv-menu-close', '×');
|
|
406
|
+
close.type = 'button';
|
|
407
|
+
close.setAttribute('aria-label', messages.CLOSE);
|
|
408
|
+
close.addEventListener('click', () => loginBtn.closeMenu());
|
|
409
|
+
header.appendChild(close);
|
|
410
|
+
dialog.appendChild(header);
|
|
411
|
+
|
|
412
|
+
if (options.info) {
|
|
413
|
+
const serviceName = loginBtn.serviceInfo?.name || '';
|
|
414
|
+
const appId = loginBtn.authSettings.authRequest.requestingAppId;
|
|
415
|
+
dialog.appendChild(menuElement('div', 'pryv-menu-info', serviceName + ' · ' + messages.APP + ': ' + appId));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const profiles = confirmLogout ? [] : auth.profiles();
|
|
419
|
+
if (options.switch) {
|
|
420
|
+
const section = switchSection(loginBtn, messages, current, profiles);
|
|
421
|
+
if (section != null) dialog.appendChild(section);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const actions = menuElement('div', 'pryv-menu-actions');
|
|
425
|
+
if (options.account && auth.accountUrl() != null) {
|
|
426
|
+
const manageText = actingAs != null
|
|
427
|
+
? (messages.MANAGE_ACCOUNT_OF || '').replace('{username}', current.username)
|
|
428
|
+
: messages.MANAGE_ACCOUNT;
|
|
429
|
+
const manage = menuElement('button', 'pryv-menu-account', manageText + ' ');
|
|
430
|
+
const arrow = menuElement('span', null, '↗');
|
|
431
|
+
arrow.setAttribute('aria-hidden', 'true');
|
|
432
|
+
manage.appendChild(arrow);
|
|
433
|
+
manage.type = 'button';
|
|
434
|
+
manage.addEventListener('click', () => {
|
|
435
|
+
auth.openAccountApp();
|
|
436
|
+
loginBtn.closeMenu();
|
|
437
|
+
});
|
|
438
|
+
actions.appendChild(manage);
|
|
439
|
+
}
|
|
440
|
+
if (options.logout) {
|
|
441
|
+
const logout = menuElement('button', 'pryv-menu-logout', messages.LOGOUT);
|
|
442
|
+
logout.type = 'button';
|
|
443
|
+
logout.addEventListener('click', () => {
|
|
444
|
+
loginBtn.closeMenu(true);
|
|
445
|
+
// Kept on the button (`pending`) so the re-init is awaited by whoever
|
|
446
|
+
// needs it, never left running unnoticed: a stray re-init would later
|
|
447
|
+
// consume whatever poll URL the page shows.
|
|
448
|
+
loginBtn.pending = confirmLogout
|
|
449
|
+
// SIGNOUT was emitted by the click already (legacy contract).
|
|
450
|
+
? loginBtn.deleteAuthorizationData().then(() => auth.init()).then(() => {})
|
|
451
|
+
: auth.signOut();
|
|
452
|
+
});
|
|
453
|
+
if (confirmLogout) {
|
|
454
|
+
const cancel = menuElement('button', 'pryv-menu-cancel', messages.CANCEL);
|
|
455
|
+
cancel.type = 'button';
|
|
456
|
+
cancel.addEventListener('click', () => loginBtn.closeMenu());
|
|
457
|
+
actions.appendChild(cancel);
|
|
458
|
+
}
|
|
459
|
+
actions.appendChild(logout);
|
|
460
|
+
// "Log out" leaves the other remembered accounts; this one forgets them all
|
|
461
|
+
if (profiles.length > 1) {
|
|
462
|
+
const logoutAll = menuElement('button', 'pryv-menu-logout-all', messages.LOGOUT_ALL);
|
|
463
|
+
logoutAll.type = 'button';
|
|
464
|
+
logoutAll.addEventListener('click', () => {
|
|
465
|
+
loginBtn.closeMenu(true);
|
|
466
|
+
loginBtn.pending = auth.signOut({ all: true });
|
|
467
|
+
});
|
|
468
|
+
actions.appendChild(logoutAll);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
dialog.appendChild(actions);
|
|
472
|
+
|
|
473
|
+
// Close on a click outside the dialog, not on a text selection that
|
|
474
|
+
// started inside it and ended outside.
|
|
475
|
+
let pressedOutside = false;
|
|
476
|
+
overlay.addEventListener('mousedown', (event) => { pressedOutside = event.target === overlay; });
|
|
477
|
+
overlay.addEventListener('click', (event) => {
|
|
478
|
+
if (event.target === overlay && pressedOutside) loginBtn.closeMenu();
|
|
479
|
+
pressedOutside = false;
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const focusables = () => Array.from(dialog.querySelectorAll('button'));
|
|
483
|
+
const onKeyDown = (event) => {
|
|
484
|
+
if (event.key === 'Escape') {
|
|
485
|
+
event.preventDefault();
|
|
486
|
+
loginBtn.closeMenu();
|
|
487
|
+
} else if (event.key === 'Tab') {
|
|
488
|
+
const list = focusables();
|
|
489
|
+
if (list.length === 0) return;
|
|
490
|
+
const first = list[0];
|
|
491
|
+
const last = list[list.length - 1];
|
|
492
|
+
const active = document.activeElement;
|
|
493
|
+
if (event.shiftKey && (active === first || !dialog.contains(active))) {
|
|
494
|
+
event.preventDefault();
|
|
495
|
+
last.focus();
|
|
496
|
+
} else if (!event.shiftKey && (active === last || !dialog.contains(active))) {
|
|
497
|
+
event.preventDefault();
|
|
498
|
+
first.focus();
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
503
|
+
|
|
504
|
+
return { overlay, dialog, onKeyDown, focusables, previousFocus: document.activeElement, confirmLogout };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The account switcher: "Switch back to <delegate>" while acting for an
|
|
509
|
+
* account; otherwise the remembered accounts and "Another account..." (when
|
|
510
|
+
* the platform has account delegation). Null when there is nothing to offer.
|
|
511
|
+
*/
|
|
512
|
+
function switchSection (loginBtn, messages, current, profiles) {
|
|
513
|
+
const auth = loginBtn.auth;
|
|
514
|
+
const run = (action) => {
|
|
515
|
+
loginBtn.closeMenu();
|
|
516
|
+
loginBtn.pending = action().catch((e) => { console.log('Error while switching account', e); });
|
|
517
|
+
};
|
|
518
|
+
const section = menuElement('div', 'pryv-menu-switch');
|
|
519
|
+
if (current?.actingAs != null) {
|
|
520
|
+
const back = menuElement('button', 'pryv-menu-switch-back',
|
|
521
|
+
(messages.SWITCH_BACK || '').replace('{username}', current.actingAs.delegate));
|
|
522
|
+
back.type = 'button';
|
|
523
|
+
back.addEventListener('click', () => run(() => auth.switchTo(null)));
|
|
524
|
+
section.appendChild(back);
|
|
525
|
+
return section;
|
|
526
|
+
}
|
|
527
|
+
const delegation = loginBtn.serviceInfo?.features?.delegation === true;
|
|
528
|
+
if (profiles.length < 2 && !delegation) return null;
|
|
529
|
+
section.appendChild(menuElement('div', 'pryv-menu-switch-title', messages.USE_FOR));
|
|
530
|
+
profiles.forEach((p) => {
|
|
531
|
+
const item = menuElement('button', 'pryv-menu-profile', p.username + ' ');
|
|
532
|
+
item.type = 'button';
|
|
533
|
+
const note = p.actingAs != null
|
|
534
|
+
? messages.VIA + ' ' + p.actingAs.delegate
|
|
535
|
+
: '(' + messages.ME + ')';
|
|
536
|
+
item.appendChild(menuElement('span', 'pryv-menu-note',
|
|
537
|
+
note + (p.available ? '' : ' · ' + messages.UNAVAILABLE)));
|
|
538
|
+
if (p.active) {
|
|
539
|
+
item.setAttribute('aria-current', 'true');
|
|
540
|
+
} else {
|
|
541
|
+
item.addEventListener('click', () => run(() => auth.switchTo(p.username)));
|
|
542
|
+
}
|
|
543
|
+
section.appendChild(item);
|
|
544
|
+
});
|
|
545
|
+
if (delegation) {
|
|
546
|
+
const other = menuElement('button', 'pryv-menu-add-account', messages.OTHER_ACCOUNT);
|
|
547
|
+
other.type = 'button';
|
|
548
|
+
other.addEventListener('click', () => run(() => auth.addAccount()));
|
|
549
|
+
section.appendChild(other);
|
|
550
|
+
}
|
|
551
|
+
return section;
|
|
552
|
+
}
|
|
553
|
+
|
|
185
554
|
function setupButton (loginBtn) {
|
|
186
555
|
loginBtn.loginButtonSpan = document.getElementById(loginBtn.authSettings.spanButtonID);
|
|
187
556
|
|
package/src/Service.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
const utils = require('./utils.js');
|
|
6
6
|
const PryvError = require('./lib/PryvError.js');
|
|
7
7
|
const MfaRequiredError = require('./lib/MfaRequiredError.js');
|
|
8
|
+
const handoff = require('./lib/handoff.js');
|
|
8
9
|
// Connection is required at the end of this file to allow circular requires.
|
|
9
10
|
const Assets = require('./ServiceAssets.js');
|
|
10
11
|
|
|
@@ -537,7 +538,9 @@ class Service {
|
|
|
537
538
|
*
|
|
538
539
|
* Returns the raw body. Inspect `body.status` to drive the flow:
|
|
539
540
|
* - `'NEED_SIGNIN'` → user has not interacted yet; keep polling.
|
|
540
|
-
* - `'ACCEPTED'` → `body.apiEndpoint` + `body.username
|
|
541
|
+
* - `'ACCEPTED'` → `body.apiEndpoint` + `body.username`, plus either
|
|
542
|
+
* `body.token` (inline) or a one-time `body.handoff` key (shared-secret
|
|
543
|
+
* delivery). Prefer `connectFromKey`, which handles both shapes.
|
|
541
544
|
* - `'REFUSED'` → user declined.
|
|
542
545
|
*
|
|
543
546
|
* @param {string} keyOrPollUrl
|
|
@@ -573,23 +576,45 @@ class Service {
|
|
|
573
576
|
* apiEndpoint), and uses this method to build a working `Connection`.
|
|
574
577
|
*
|
|
575
578
|
* The implementation polls `<access>/<key>` once; the call MUST be
|
|
576
|
-
* made while the access is still in the ACCEPTED
|
|
577
|
-
*
|
|
579
|
+
* made while the access request is still readable in the ACCEPTED
|
|
580
|
+
* state. Servers keep a decided request only for a short retention
|
|
581
|
+
* window after it is first polled (default 2 minutes, operator setting
|
|
582
|
+
* `access.terminalRetentionMs`), so call this promptly after the flow
|
|
583
|
+
* completes; afterwards the key is unknown. (`expireAfter` on the
|
|
584
|
+
* access request is the lifetime of the access created, not of the key.)
|
|
585
|
+
*
|
|
586
|
+
* When the request asked for shared-secret delivery the ACCEPTED body
|
|
587
|
+
* carries a one-time `handoff` key instead of the token; this redeems it
|
|
588
|
+
* exactly once and caches the result keyed by `key`, so it is safe to call
|
|
589
|
+
* more than once for the same key (the second call reuses the cache rather
|
|
590
|
+
* than hitting the already-consumed one-time secret). A retrieve that finds
|
|
591
|
+
* the secret gone throws a `PryvError` (id `credential-handoff-failed`);
|
|
592
|
+
* restart the auth request.
|
|
578
593
|
*
|
|
579
594
|
* @param {string} key - polling key from `startAccessRequest`
|
|
580
595
|
* @returns {Promise<Connection>}
|
|
581
596
|
* @throws {PryvError} if the key is not ACCEPTED (NEED_SIGNIN, REFUSED, ERROR)
|
|
597
|
+
* or the hand-off secret could not be retrieved
|
|
582
598
|
*/
|
|
583
599
|
async connectFromKey (key) {
|
|
584
600
|
if (!key) {
|
|
585
601
|
throw new PryvError('connectFromKey requires a key');
|
|
586
602
|
}
|
|
603
|
+
// A prior resolve (this call, or the AuthController polling loop) may have
|
|
604
|
+
// already redeemed the one-time secret; reuse it rather than re-polling.
|
|
605
|
+
const cached = handoff.cacheGet(key);
|
|
606
|
+
if (cached != null) return new Connection(cached.apiEndpoint, this);
|
|
607
|
+
|
|
587
608
|
const body = await this.pollAccessRequest(key);
|
|
588
609
|
if (body.status !== 'ACCEPTED') {
|
|
589
610
|
throw new PryvError(
|
|
590
611
|
'connectFromKey: access is not ACCEPTED (status=' + body.status + ')'
|
|
591
612
|
);
|
|
592
613
|
}
|
|
614
|
+
if (handoff.isHandoffBody(body)) {
|
|
615
|
+
const entry = await handoff.resolveHandoff(body, key);
|
|
616
|
+
return new Connection(entry.apiEndpoint, this);
|
|
617
|
+
}
|
|
593
618
|
if (!body.apiEndpoint) {
|
|
594
619
|
throw new PryvError(
|
|
595
620
|
'connectFromKey: ACCEPTED response missing apiEndpoint'
|
package/src/ServiceAssets.js
CHANGED
|
@@ -132,6 +132,9 @@ class ServiceAssets {
|
|
|
132
132
|
module.exports = ServiceAssets;
|
|
133
133
|
|
|
134
134
|
function loadCSS (url) {
|
|
135
|
+
// Once per page: the controller re-initializes (and reloads assets) on
|
|
136
|
+
// logout and on a cancelled logout.
|
|
137
|
+
if (document.getElementById(url) != null) return;
|
|
135
138
|
const head = document.getElementsByTagName('head')[0];
|
|
136
139
|
const link = document.createElement('link');
|
|
137
140
|
link.id = url;
|
package/src/SharedSecrets.js
CHANGED
|
@@ -157,7 +157,10 @@ async function retrieve (apiEndpoint, key, options = {}) {
|
|
|
157
157
|
const err = /** @type {Error & { id?: string, returnUrl?: string }} */ (
|
|
158
158
|
new Error(parsed?.error?.message || 'Shared secret unavailable.')
|
|
159
159
|
);
|
|
160
|
-
|
|
160
|
+
// Prefer the fine machine id the method carries in `data.id`
|
|
161
|
+
// (e.g. `shared-secret-unavailable`) over the coarse HTTP id (`forbidden`),
|
|
162
|
+
// so a caller can tell a consumed/expired secret from a generic refusal.
|
|
163
|
+
err.id = parsed?.error?.data?.id || parsed?.error?.id;
|
|
161
164
|
err.returnUrl = parsed?.error?.data?.returnUrl;
|
|
162
165
|
throw err;
|
|
163
166
|
}
|