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/src/Service.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
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');
|
|
9
|
+
const pollUrls = require('./lib/pollUrls.js');
|
|
8
10
|
// Connection is required at the end of this file to allow circular requires.
|
|
9
11
|
const Assets = require('./ServiceAssets.js');
|
|
10
12
|
|
|
@@ -203,9 +205,8 @@ class Service {
|
|
|
203
205
|
}
|
|
204
206
|
|
|
205
207
|
if (!body || !body.token) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
);
|
|
208
|
+
// The answer rides on `innerObject`, never in the message (it may hold secrets).
|
|
209
|
+
throw new PryvError('Invalid login response: no token', body);
|
|
209
210
|
}
|
|
210
211
|
return new Connection(
|
|
211
212
|
Service.buildAPIEndpoint(await this.info(), username, body.token),
|
|
@@ -254,9 +255,7 @@ class Service {
|
|
|
254
255
|
});
|
|
255
256
|
if (!response.ok) throw PryvError.fromApiResponse(response, body);
|
|
256
257
|
if (!body || !body.token) {
|
|
257
|
-
throw new PryvError(
|
|
258
|
-
'mfa.verify did not return a token: ' + JSON.stringify(body)
|
|
259
|
-
);
|
|
258
|
+
throw new PryvError('mfa.verify did not return a token', body);
|
|
260
259
|
}
|
|
261
260
|
return new Connection(
|
|
262
261
|
Service.buildAPIEndpoint(await this.info(), userId, body.token),
|
|
@@ -487,7 +486,9 @@ class Service {
|
|
|
487
486
|
* @param {string[]} [authRequest.consent.optIn] - ids offered NOT
|
|
488
487
|
* pre-selected, so the user has to choose them.
|
|
489
488
|
* @param {string} [authRequest.languageCode='en']
|
|
490
|
-
* @param {string
|
|
489
|
+
* @param {string} [authRequest.returnURL] - URL the auth page returns
|
|
490
|
+
* to after the decision, sent as is (the 'auto#' / 'self#' shortcuts
|
|
491
|
+
* are resolved only by the sign-in button, not here).
|
|
491
492
|
* @param {string} [authRequest.referer]
|
|
492
493
|
* @param {Object} [authRequest.clientData]
|
|
493
494
|
* @param {string} [authRequest.deviceName]
|
|
@@ -510,9 +511,7 @@ class Service {
|
|
|
510
511
|
);
|
|
511
512
|
if (!response.ok) throw PryvError.fromApiResponse(response, body);
|
|
512
513
|
if (!body || !body.key || !body.poll) {
|
|
513
|
-
throw new PryvError(
|
|
514
|
-
'Invalid access-request response: ' + JSON.stringify(body)
|
|
515
|
-
);
|
|
514
|
+
throw new PryvError('Invalid access-request response: no key or poll', body);
|
|
516
515
|
}
|
|
517
516
|
const envelope = {
|
|
518
517
|
key: body.key,
|
|
@@ -525,19 +524,27 @@ class Service {
|
|
|
525
524
|
// all-or-nothing, which a caller may want to know before showing the
|
|
526
525
|
// approve link.
|
|
527
526
|
if (body.consent != null) envelope.consent = body.consent;
|
|
527
|
+
// polling by key (pollAccessRequest, connectFromKey) then reaches the
|
|
528
|
+
// core that holds the request
|
|
529
|
+
pollUrls.remember(envelope.key, envelope.poll);
|
|
528
530
|
return envelope;
|
|
529
531
|
}
|
|
530
532
|
|
|
531
533
|
/**
|
|
532
534
|
* Poll an in-progress access request once. Accepts either:
|
|
533
|
-
* - a `key` returned by `startAccessRequest` (poll URL
|
|
535
|
+
* - a `key` returned by `startAccessRequest` (polls the poll URL the
|
|
536
|
+
* server issued for it when this process started the request, else
|
|
534
537
|
* `serviceInfo.access + key`)
|
|
535
538
|
* - a full poll URL (use as-is — recommended, since the server-issued
|
|
536
|
-
* URL is canonical and may
|
|
539
|
+
* URL is canonical and may point at a specific core: on a multi-core
|
|
540
|
+
* platform `access + key` can reach a core that does not know the
|
|
541
|
+
* request).
|
|
537
542
|
*
|
|
538
543
|
* Returns the raw body. Inspect `body.status` to drive the flow:
|
|
539
544
|
* - `'NEED_SIGNIN'` → user has not interacted yet; keep polling.
|
|
540
|
-
* - `'ACCEPTED'` → `body.apiEndpoint` + `body.username
|
|
545
|
+
* - `'ACCEPTED'` → `body.apiEndpoint` + `body.username`, plus either
|
|
546
|
+
* `body.token` (inline) or a one-time `body.handoff` key (shared-secret
|
|
547
|
+
* delivery). Prefer `connectFromKey`, which handles both shapes.
|
|
541
548
|
* - `'REFUSED'` → user declined.
|
|
542
549
|
*
|
|
543
550
|
* @param {string} keyOrPollUrl
|
|
@@ -550,8 +557,14 @@ class Service {
|
|
|
550
557
|
}
|
|
551
558
|
let pollUrl = keyOrPollUrl;
|
|
552
559
|
if (!/^https?:\/\//.test(keyOrPollUrl)) {
|
|
553
|
-
|
|
554
|
-
|
|
560
|
+
// the core-specific URL the server issued, when this process started
|
|
561
|
+
// the request; `access + key` may reach another core on a multi-core
|
|
562
|
+
// platform
|
|
563
|
+
pollUrl = pollUrls.lookup(keyOrPollUrl);
|
|
564
|
+
if (pollUrl == null) {
|
|
565
|
+
const serviceInfo = await this.info();
|
|
566
|
+
pollUrl = serviceInfo.access + keyOrPollUrl;
|
|
567
|
+
}
|
|
555
568
|
}
|
|
556
569
|
const { response, body } = await utils.fetchGet(pollUrl);
|
|
557
570
|
// 403 with status=REFUSED is the canonical "user declined" terminal
|
|
@@ -572,7 +585,9 @@ class Service {
|
|
|
572
585
|
* `key` returned by the auth-flow (not the underlying token /
|
|
573
586
|
* apiEndpoint), and uses this method to build a working `Connection`.
|
|
574
587
|
*
|
|
575
|
-
* The implementation polls
|
|
588
|
+
* The implementation polls the request once (at the server-issued poll
|
|
589
|
+
* URL when this process started it, else `<access>/<key>`, which may
|
|
590
|
+
* miss the request on a multi-core platform); the call MUST be
|
|
576
591
|
* made while the access request is still readable in the ACCEPTED
|
|
577
592
|
* state. Servers keep a decided request only for a short retention
|
|
578
593
|
* window after it is first polled (default 2 minutes, operator setting
|
|
@@ -580,20 +595,38 @@ class Service {
|
|
|
580
595
|
* completes; afterwards the key is unknown. (`expireAfter` on the
|
|
581
596
|
* access request is the lifetime of the access created, not of the key.)
|
|
582
597
|
*
|
|
598
|
+
* When the request asked for shared-secret delivery the ACCEPTED body
|
|
599
|
+
* carries a one-time `handoff` key instead of the token; this redeems it
|
|
600
|
+
* exactly once and caches the result keyed by `key`, so it is safe to call
|
|
601
|
+
* more than once for the same key (the second call reuses the cache rather
|
|
602
|
+
* than hitting the already-consumed one-time secret). A retrieve that finds
|
|
603
|
+
* the secret gone throws a `PryvError` (id `credential-handoff-failed`);
|
|
604
|
+
* restart the auth request.
|
|
605
|
+
*
|
|
583
606
|
* @param {string} key - polling key from `startAccessRequest`
|
|
584
607
|
* @returns {Promise<Connection>}
|
|
585
608
|
* @throws {PryvError} if the key is not ACCEPTED (NEED_SIGNIN, REFUSED, ERROR)
|
|
609
|
+
* or the hand-off secret could not be retrieved
|
|
586
610
|
*/
|
|
587
611
|
async connectFromKey (key) {
|
|
588
612
|
if (!key) {
|
|
589
613
|
throw new PryvError('connectFromKey requires a key');
|
|
590
614
|
}
|
|
615
|
+
// A prior resolve (this call, or the AuthController polling loop) may have
|
|
616
|
+
// already redeemed the one-time secret; reuse it rather than re-polling.
|
|
617
|
+
const cached = handoff.cacheGet(key);
|
|
618
|
+
if (cached != null) return new Connection(cached.apiEndpoint, this);
|
|
619
|
+
|
|
591
620
|
const body = await this.pollAccessRequest(key);
|
|
592
621
|
if (body.status !== 'ACCEPTED') {
|
|
593
622
|
throw new PryvError(
|
|
594
623
|
'connectFromKey: access is not ACCEPTED (status=' + body.status + ')'
|
|
595
624
|
);
|
|
596
625
|
}
|
|
626
|
+
if (handoff.isHandoffBody(body)) {
|
|
627
|
+
const entry = await handoff.resolveHandoff(body, key);
|
|
628
|
+
return new Connection(entry.apiEndpoint, this);
|
|
629
|
+
}
|
|
597
630
|
if (!body.apiEndpoint) {
|
|
598
631
|
throw new PryvError(
|
|
599
632
|
'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
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -160,7 +160,8 @@ declare module 'pryv' {
|
|
|
160
160
|
*/
|
|
161
161
|
export class PryvError extends globalThis.Error {
|
|
162
162
|
constructor(message: string, innerObject?: globalThis.Error | object);
|
|
163
|
-
|
|
163
|
+
/** `'PryvError'`, or the subclass name (`'MfaRequiredError'`, ...). */
|
|
164
|
+
name: string;
|
|
164
165
|
innerObject?: globalThis.Error | object;
|
|
165
166
|
id?: string;
|
|
166
167
|
status?: number;
|
|
@@ -684,6 +685,19 @@ declare module 'pryv' {
|
|
|
684
685
|
export type AccessInfo = Access & {
|
|
685
686
|
calls: KeyValue;
|
|
686
687
|
user: KeyValue;
|
|
688
|
+
/**
|
|
689
|
+
* Present when the access works through account delegation: a delegate
|
|
690
|
+
* token or an access granted through it (`isDelegatedAccess`, with
|
|
691
|
+
* `grantedVia: 'app'` for an app access granted by the delegate), or the
|
|
692
|
+
* control access of a delegation (`kind: 'control'`).
|
|
693
|
+
*/
|
|
694
|
+
delegation?: {
|
|
695
|
+
isDelegatedAccess?: true;
|
|
696
|
+
kind?: 'control';
|
|
697
|
+
controlledUsername: string;
|
|
698
|
+
delegate: { username: string; hostSlug?: string };
|
|
699
|
+
grantedVia?: 'app';
|
|
700
|
+
};
|
|
687
701
|
}
|
|
688
702
|
|
|
689
703
|
export type EventAPICallRes = {
|
|
@@ -793,12 +807,16 @@ declare module 'pryv' {
|
|
|
793
807
|
terms: string;
|
|
794
808
|
eventTypes: string;
|
|
795
809
|
version?: string;
|
|
810
|
+
/** Root URL of the platform's account app, when the platform names one. */
|
|
811
|
+
account?: string;
|
|
796
812
|
assets?: {
|
|
797
813
|
definitions: string;
|
|
798
814
|
};
|
|
799
815
|
serial?: string;
|
|
800
816
|
features?: {
|
|
801
817
|
noHF?: boolean;
|
|
818
|
+
/** Account delegation is available on this platform. */
|
|
819
|
+
delegation?: boolean;
|
|
802
820
|
[key: string]: any;
|
|
803
821
|
};
|
|
804
822
|
};
|
|
@@ -874,10 +892,17 @@ declare module 'pryv' {
|
|
|
874
892
|
/** Echoed only by a core that understood `authRequest.consent`. */
|
|
875
893
|
consent?: AuthRequestConsentForm;
|
|
876
894
|
}>;
|
|
895
|
+
/**
|
|
896
|
+
* Poll an access request once, by its full poll URL (recommended) or its
|
|
897
|
+
* `key`. A key started in this process polls the poll URL the server issued
|
|
898
|
+
* for it (the core holding the request); an unknown key polls
|
|
899
|
+
* `access + key`, which may miss the request on a multi-core platform.
|
|
900
|
+
*/
|
|
877
901
|
pollAccessRequest(keyOrPollUrl: string): Promise<any>;
|
|
878
902
|
/**
|
|
879
903
|
* Resolve an auth-flow polling `key` (from {@link Service.startAccessRequest})
|
|
880
|
-
* into a working {@link Connection}. Polls the access request once
|
|
904
|
+
* into a working {@link Connection}. Polls the access request once (as
|
|
905
|
+
* {@link Service.pollAccessRequest} does with a key); throws a
|
|
881
906
|
* {@link PryvError} unless the access is `ACCEPTED`.
|
|
882
907
|
*/
|
|
883
908
|
connectFromKey(key: string): Promise<Connection>;
|
|
@@ -964,7 +989,17 @@ declare module 'pryv' {
|
|
|
964
989
|
| 'NEED_SIGNIN'
|
|
965
990
|
| 'ACCEPTED'
|
|
966
991
|
| 'SIGNOUT'
|
|
967
|
-
| 'REFUSED'
|
|
992
|
+
| 'REFUSED'
|
|
993
|
+
| 'SWITCHING';
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* An account remembered by the sign-in button. `actingAs` marks an account
|
|
997
|
+
* used through account delegation (`delegate`: the person acting).
|
|
998
|
+
*/
|
|
999
|
+
export type AuthProfile = {
|
|
1000
|
+
username: string;
|
|
1001
|
+
actingAs?: { username: string; delegate: string };
|
|
1002
|
+
};
|
|
968
1003
|
|
|
969
1004
|
export type StateChangeTypes = {
|
|
970
1005
|
ERROR: {
|
|
@@ -991,16 +1026,42 @@ declare module 'pryv' {
|
|
|
991
1026
|
}>;
|
|
992
1027
|
consent?: AuthRequestConsentForm;
|
|
993
1028
|
requestingAppId: string;
|
|
994
|
-
|
|
1029
|
+
returnURL?: string | null;
|
|
995
1030
|
serviceInfo?: ServiceInfo;
|
|
996
1031
|
};
|
|
997
1032
|
ACCEPTED: {
|
|
998
1033
|
serviceInfo?: ServiceInfo;
|
|
999
1034
|
apiEndpoint: string;
|
|
1000
1035
|
username: string;
|
|
1036
|
+
/** Present on inline delivery; absent when a one-time `handoff` key is used. */
|
|
1001
1037
|
token?: string;
|
|
1038
|
+
/**
|
|
1039
|
+
* Key of the auth request that just completed (use it with
|
|
1040
|
+
* `connectFromKey`). Absent when the account comes from stored
|
|
1041
|
+
* credentials: page load, an account switch without sign-in, or the
|
|
1042
|
+
* return to the previous account after a switch that did not complete.
|
|
1043
|
+
* Also absent on the return from a sign-in by redirection (`returnURL`,
|
|
1044
|
+
* including `'auto#'` on a phone or tablet): that state carries
|
|
1045
|
+
* `apiEndpoint` with its token instead. Handle both:
|
|
1046
|
+
* `state.key ? connectFromKey(state.key) : new Connection(state.apiEndpoint)`.
|
|
1047
|
+
*/
|
|
1048
|
+
key?: string;
|
|
1049
|
+
/** Display hint posted by the auth page for a grant on a controlled account; `accessInfo().delegation` is authoritative. */
|
|
1050
|
+
delegation?: {
|
|
1051
|
+
isDelegatedAccess: true;
|
|
1052
|
+
controlledUsername: string;
|
|
1053
|
+
delegate: { username: string; hostSlug?: string };
|
|
1054
|
+
};
|
|
1055
|
+
profile?: AuthProfile;
|
|
1056
|
+
/** One-time credential hand-off key (shared-secret delivery), in place of `token`. */
|
|
1057
|
+
handoff?: { type: 'shared-secret'; key: string };
|
|
1002
1058
|
};
|
|
1003
1059
|
SIGNOUT: {};
|
|
1060
|
+
SWITCHING: {
|
|
1061
|
+
from: string | null;
|
|
1062
|
+
/** null: the account is chosen in the sign-in popup */
|
|
1063
|
+
to: string | null;
|
|
1064
|
+
};
|
|
1004
1065
|
REFUSED: {
|
|
1005
1066
|
reasonID?: string;
|
|
1006
1067
|
message?: string;
|
|
@@ -1034,21 +1095,70 @@ declare module 'pryv' {
|
|
|
1034
1095
|
serviceInfo?: ServiceInfo;
|
|
1035
1096
|
};
|
|
1036
1097
|
|
|
1098
|
+
/**
|
|
1099
|
+
* Account menu entries of the sign-in button. `false` on an entry, or its
|
|
1100
|
+
* name in `hide`, hides it.
|
|
1101
|
+
*/
|
|
1102
|
+
export type LoginButtonMenuSettings = {
|
|
1103
|
+
logout?: boolean;
|
|
1104
|
+
account?: boolean;
|
|
1105
|
+
info?: boolean;
|
|
1106
|
+
/** Account switcher (remembered accounts, "Another account...", "Switch back"). */
|
|
1107
|
+
switch?: boolean;
|
|
1108
|
+
hide?: Array<'logout' | 'account' | 'info' | 'switch'>;
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1037
1111
|
export type AuthSettings = {
|
|
1038
1112
|
spanButtonID?: string;
|
|
1039
1113
|
onStateChange?: (state: StateChange<States>) => void;
|
|
1040
|
-
|
|
1114
|
+
/**
|
|
1115
|
+
* Account menu shown when the signed-in button is clicked (default: on).
|
|
1116
|
+
* `false` restores the plain logout confirmation.
|
|
1117
|
+
*/
|
|
1118
|
+
menu?: LoginButtonMenuSettings | false;
|
|
1119
|
+
/** Root URL of the account app; overrides the service's `account`. */
|
|
1120
|
+
accountUrl?: string;
|
|
1121
|
+
/** Most accounts remembered for this app (default 5); the least recently used is forgotten first. */
|
|
1122
|
+
maxProfiles?: number;
|
|
1041
1123
|
authRequest: {
|
|
1042
1124
|
requestingAppId: string;
|
|
1043
1125
|
languageCode?: string;
|
|
1044
1126
|
requestedPermissions: AuthRequestedPermission[];
|
|
1045
1127
|
consent?: AuthRequestConsent;
|
|
1046
|
-
|
|
1128
|
+
/**
|
|
1129
|
+
* Where the sign-in happens: `'auto#'` (default, also when unset or
|
|
1130
|
+
* `false`) opens a popup on desktop and redirects on a phone or tablet;
|
|
1131
|
+
* `'self#'` always redirects and comes back to the current page; a URL
|
|
1132
|
+
* always redirects and comes back to that URL. Must end with `#`, `?`
|
|
1133
|
+
* or `&`.
|
|
1134
|
+
*/
|
|
1135
|
+
returnURL?: string | false;
|
|
1136
|
+
/**
|
|
1137
|
+
* Your own auth page for this request (query parameters allowed, e.g. a
|
|
1138
|
+
* `username` hint or `backUrl` / `backLabel`). The platform honours it
|
|
1139
|
+
* only when it matches one of its `access:trustedAuthUrls` entries and
|
|
1140
|
+
* refuses the request otherwise; unset, the platform's default auth page
|
|
1141
|
+
* is used.
|
|
1142
|
+
*/
|
|
1143
|
+
authUrl?: string;
|
|
1047
1144
|
referer?: string;
|
|
1048
1145
|
clientData?: KeyValue;
|
|
1049
1146
|
deviceName?: string;
|
|
1050
1147
|
expireAfter?: number;
|
|
1148
|
+
/**
|
|
1149
|
+
* Credential delivery mode. Defaults to `'shared-secret'`: the token is
|
|
1150
|
+
* delivered through a one-time secret instead of the ACCEPTED poll, and
|
|
1151
|
+
* `connectFromKey` redeems it. `'inline'` opts out (no field sent, legacy
|
|
1152
|
+
* inline delivery). An older core ignores the field and delivers inline.
|
|
1153
|
+
*/
|
|
1154
|
+
credentialHandoff?: 'shared-secret' | 'inline';
|
|
1051
1155
|
serviceInfo?: Partial<ServiceInfo>;
|
|
1156
|
+
/**
|
|
1157
|
+
* Whether the sign-in may grant the access for an account the user
|
|
1158
|
+
* controls through account delegation: 'allow' (server default),
|
|
1159
|
+
* 'deny', or the username to preselect.
|
|
1160
|
+
*/
|
|
1161
|
+
actAs?: 'allow' | 'deny' | string;
|
|
1052
1162
|
};
|
|
1053
1163
|
};
|
|
1054
1164
|
|
|
@@ -1072,6 +1182,7 @@ declare module 'pryv' {
|
|
|
1072
1182
|
AUTHORIZED: 'ACCEPTED';
|
|
1073
1183
|
SIGNOUT: 'SIGNOUT';
|
|
1074
1184
|
REFUSED: 'REFUSED';
|
|
1185
|
+
SWITCHING: 'SWITCHING';
|
|
1075
1186
|
};
|
|
1076
1187
|
|
|
1077
1188
|
type AuthStatePayload = {
|
|
@@ -1080,9 +1191,25 @@ declare module 'pryv' {
|
|
|
1080
1191
|
error?: Error | unknown;
|
|
1081
1192
|
};
|
|
1082
1193
|
|
|
1083
|
-
|
|
1194
|
+
/** A remembered account as stored (credentials included). */
|
|
1195
|
+
export type StoredAuthProfile = AuthProfile & {
|
|
1084
1196
|
apiEndpoint: string;
|
|
1085
|
-
|
|
1197
|
+
unavailable?: true;
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Stored sign-in data. The top-level `apiEndpoint` / `username` are the
|
|
1202
|
+
* active account (absent after logging out of one of several accounts);
|
|
1203
|
+
* `profiles` lists every remembered account, most recently used first.
|
|
1204
|
+
* The default button stores the two parts in two cookies.
|
|
1205
|
+
*/
|
|
1206
|
+
export type StoredAuthorizationData = {
|
|
1207
|
+
apiEndpoint?: string;
|
|
1208
|
+
username?: string;
|
|
1209
|
+
actingAs?: AuthProfile['actingAs'];
|
|
1210
|
+
/** Auth page of the sign-in (without query), kept to locate the account app. */
|
|
1211
|
+
authUrl?: string;
|
|
1212
|
+
profiles?: StoredAuthProfile[];
|
|
1086
1213
|
} | null;
|
|
1087
1214
|
|
|
1088
1215
|
export type CustomLoginButton = {
|
|
@@ -1093,6 +1220,12 @@ declare module 'pryv' {
|
|
|
1093
1220
|
saveAuthorizationData?: (authData: StoredAuthorizationData) => void;
|
|
1094
1221
|
deleteAuthorizationData?: () => Promise<void>;
|
|
1095
1222
|
finishAuthProcessAfterRedirection?: (authController: AuthController) => Promise<void>;
|
|
1223
|
+
/**
|
|
1224
|
+
* Called when the signed-in button is clicked. Return `false` to fall
|
|
1225
|
+
* back to the SIGNOUT state (logout confirmation); log out from the menu
|
|
1226
|
+
* with `AuthController.signOut()`.
|
|
1227
|
+
*/
|
|
1228
|
+
showMenu?: () => boolean | void;
|
|
1096
1229
|
};
|
|
1097
1230
|
|
|
1098
1231
|
export class LoginButton implements CustomLoginButton {
|
|
@@ -1115,6 +1248,11 @@ declare module 'pryv' {
|
|
|
1115
1248
|
saveAuthorizationData(authData: StoredAuthorizationData): void;
|
|
1116
1249
|
deleteAuthorizationData(): Promise<void>;
|
|
1117
1250
|
finishAuthProcessAfterRedirection(authController: AuthController): Promise<void>;
|
|
1251
|
+
showMenu(): boolean;
|
|
1252
|
+
/** Resolves when the re-initialization it may start (a dismissed "Log out?") is done. */
|
|
1253
|
+
closeMenu(): Promise<void>;
|
|
1254
|
+
/** The re-initialization started by the last menu action, if any. */
|
|
1255
|
+
pending?: Promise<void>;
|
|
1118
1256
|
}
|
|
1119
1257
|
|
|
1120
1258
|
export class AuthController {
|
|
@@ -1136,11 +1274,34 @@ declare module 'pryv' {
|
|
|
1136
1274
|
stopAuthRequest(msg: string): void;
|
|
1137
1275
|
handleClick(): Promise<void>;
|
|
1138
1276
|
getReturnURL(
|
|
1139
|
-
returnURL?: string,
|
|
1277
|
+
returnURL?: string | false,
|
|
1140
1278
|
windowLocationForTest?: string,
|
|
1141
|
-
navigatorForTests?: string,
|
|
1279
|
+
navigatorForTests?: string | Navigator,
|
|
1142
1280
|
): string | boolean;
|
|
1143
|
-
|
|
1281
|
+
/** `overrides`: auth request fields for this request only; `previous`: state to return to if an account switch does not complete. */
|
|
1282
|
+
startAuthRequest(overrides?: Partial<AuthSettings['authRequest']>, previous?: AuthStatePayload): Promise<AuthRequestResponse>;
|
|
1283
|
+
/**
|
|
1284
|
+
* Log out: emits SIGNOUT once, forgets the active account (every
|
|
1285
|
+
* remembered account with `all`), re-initializes.
|
|
1286
|
+
*/
|
|
1287
|
+
signOut(options?: { all?: boolean }): Promise<void>;
|
|
1288
|
+
/** The remembered accounts, most recently used first. */
|
|
1289
|
+
profiles(): Array<AuthProfile & { active: boolean; available: boolean }>;
|
|
1290
|
+
/** The signed-in account, or null. */
|
|
1291
|
+
currentProfile(): AuthProfile | null;
|
|
1292
|
+
/**
|
|
1293
|
+
* Switch account (`null`: the signed-in person's own account). A
|
|
1294
|
+
* remembered account with a valid access needs no sign-in; otherwise the
|
|
1295
|
+
* auth request runs again with `actAs`. Emits SWITCHING, ends in ACCEPTED,
|
|
1296
|
+
* or back on the previous account when the sign-in is refused.
|
|
1297
|
+
*/
|
|
1298
|
+
switchTo(username: string | null): Promise<void>;
|
|
1299
|
+
/** Sign in to one more account (`actAs: 'allow'`), keeping the remembered ones. */
|
|
1300
|
+
addAccount(): Promise<void>;
|
|
1301
|
+
/** Account app profile URL, or null when it cannot be determined. */
|
|
1302
|
+
accountUrl(): string | null;
|
|
1303
|
+
/** Opens the account app in a new tab; returns the URL, or null. */
|
|
1304
|
+
openAccountApp(): string | null;
|
|
1144
1305
|
set state(newState: AuthStatePayload);
|
|
1145
1306
|
get state(): AuthStatePayload;
|
|
1146
1307
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Credential hand-off (shared-secret delivery) support for the auth-request
|
|
8
|
+
* flow.
|
|
9
|
+
*
|
|
10
|
+
* When an access request set `credentialHandoff: 'shared-secret'`, the ACCEPTED
|
|
11
|
+
* poll body carries a one-time `handoff.key` and a token-less `apiEndpoint`
|
|
12
|
+
* instead of the token. This module redeems that key ONCE against the user's
|
|
13
|
+
* core (`shared-secrets/retrieve`, no credentials needed) and turns the result
|
|
14
|
+
* into a token-bearing apiEndpoint the rest of the library already understands.
|
|
15
|
+
*
|
|
16
|
+
* A module-level cache keyed by the auth-flow poll key holds the result for a
|
|
17
|
+
* short TTL: `pryv.connectFromKey(key, ...)` builds a fresh `Service` on every
|
|
18
|
+
* call and the `AuthController` polling loop also reads the ACCEPTED body, so
|
|
19
|
+
* without a shared cache the one-shot secret would be redeemed twice and the
|
|
20
|
+
* second read would fail. Whoever redeems first fills the cache; the others
|
|
21
|
+
* reuse it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const utils = require('../utils');
|
|
25
|
+
const SharedSecrets = require('../SharedSecrets');
|
|
26
|
+
const PryvError = require('./PryvError');
|
|
27
|
+
|
|
28
|
+
/** How long a redeemed credential stays cached (10 min, or until sign-out). */
|
|
29
|
+
const TTL_MS = 10 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
/** poll key -> { apiEndpoint, username, token, expiresAt } */
|
|
32
|
+
const cache = new Map();
|
|
33
|
+
/** poll key -> Promise, so concurrent resolves for one key redeem once. */
|
|
34
|
+
const inflight = new Map();
|
|
35
|
+
|
|
36
|
+
function cacheGet (key) {
|
|
37
|
+
if (key == null) return null;
|
|
38
|
+
const entry = cache.get(key);
|
|
39
|
+
if (entry == null) return null;
|
|
40
|
+
if (Date.now() > entry.expiresAt) {
|
|
41
|
+
cache.delete(key);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return entry;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Drop expired entries so cached tokens do not linger for the process
|
|
48
|
+
* lifetime in a long-running Node service (a browser page is short-lived, but
|
|
49
|
+
* the same module runs server-side too). Called on every set; the map holds
|
|
50
|
+
* one entry per in-flight sign-in, so the scan is trivially small. */
|
|
51
|
+
function sweep () {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
for (const [k, entry] of cache) {
|
|
54
|
+
if (now > entry.expiresAt) cache.delete(k);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cacheSet (key, value) {
|
|
59
|
+
if (key == null) return;
|
|
60
|
+
sweep();
|
|
61
|
+
cache.set(key, Object.assign({}, value, { expiresAt: Date.now() + TTL_MS }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Drop one entry (sign-out). Passing no key is an explicit clear-all. */
|
|
65
|
+
function cacheClear (key) {
|
|
66
|
+
if (arguments.length === 0) cache.clear();
|
|
67
|
+
else cache.delete(key);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Does an ACCEPTED poll body deliver by shared-secret hand-off? */
|
|
71
|
+
function isHandoffBody (body) {
|
|
72
|
+
return body != null && body.handoff != null &&
|
|
73
|
+
body.handoff.type === 'shared-secret' && typeof body.handoff.key === 'string';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Redeem a hand-off poll body into a token-bearing apiEndpoint, caching the
|
|
78
|
+
* result under `cacheKey`. Throws a `PryvError` (id `credential-handoff-failed`)
|
|
79
|
+
* telling the caller to restart the auth request when the one-time secret is
|
|
80
|
+
* gone (already redeemed, expired) or the retrieve fails.
|
|
81
|
+
*
|
|
82
|
+
* @param {Object} pollBody an ACCEPTED body carrying `apiEndpoint` + `handoff.key`
|
|
83
|
+
* @param {string} [cacheKey] the auth-flow poll key, so a repeat resolve reuses this
|
|
84
|
+
* @returns {Promise<{ apiEndpoint: string, username: string, token: string }>}
|
|
85
|
+
*/
|
|
86
|
+
async function resolveHandoff (pollBody, cacheKey) {
|
|
87
|
+
const cached = cacheGet(cacheKey);
|
|
88
|
+
if (cached != null) return cached;
|
|
89
|
+
// De-duplicate concurrent redemptions of the same key (e.g. React
|
|
90
|
+
// StrictMode double-invoking an effect that calls connectFromKey twice):
|
|
91
|
+
// both would otherwise miss the cache and the second would 403 the
|
|
92
|
+
// already-consumed one-time secret.
|
|
93
|
+
if (cacheKey != null && inflight.has(cacheKey)) return inflight.get(cacheKey);
|
|
94
|
+
|
|
95
|
+
const promise = doResolve(pollBody, cacheKey);
|
|
96
|
+
if (cacheKey == null) return promise;
|
|
97
|
+
inflight.set(cacheKey, promise);
|
|
98
|
+
try {
|
|
99
|
+
return await promise;
|
|
100
|
+
} finally {
|
|
101
|
+
inflight.delete(cacheKey);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function doResolve (pollBody, cacheKey) {
|
|
106
|
+
let result;
|
|
107
|
+
try {
|
|
108
|
+
result = await SharedSecrets.retrieve(pollBody.apiEndpoint, pollBody.handoff.key);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
const pe = new PryvError(
|
|
111
|
+
'Credential hand-off could not be retrieved (' +
|
|
112
|
+
(err && (err.id || err.message)) + '); restart the auth request.',
|
|
113
|
+
err
|
|
114
|
+
);
|
|
115
|
+
// Stable id for callers that branch on the hand-off failure; the finer
|
|
116
|
+
// reason (`shared-secret-unavailable`) rides on `innerObject.id`, which
|
|
117
|
+
// `SharedSecrets.retrieve` now takes from the refusal's `data.id`.
|
|
118
|
+
pe.id = 'credential-handoff-failed';
|
|
119
|
+
throw pe;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const secret = (result && result.secret) || {};
|
|
123
|
+
if (typeof secret.token !== 'string' || typeof secret.apiEndpoint !== 'string') {
|
|
124
|
+
const pe = new PryvError('Credential hand-off returned an incomplete secret.');
|
|
125
|
+
pe.id = 'credential-handoff-failed';
|
|
126
|
+
throw pe;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The secret's apiEndpoint may be token-less or token-bearing; rebuild a
|
|
130
|
+
// canonical token-bearing endpoint from the authoritative `token` so the
|
|
131
|
+
// rest of the library (Connection, the LoginButton cookie) is unchanged.
|
|
132
|
+
const { endpoint } = utils.extractTokenAndAPIEndpoint(secret.apiEndpoint);
|
|
133
|
+
const apiEndpoint = utils.buildAPIEndpoint({ endpoint, token: secret.token });
|
|
134
|
+
const entry = { apiEndpoint, username: secret.username, token: secret.token };
|
|
135
|
+
cacheSet(cacheKey, entry);
|
|
136
|
+
return entry;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { resolveHandoff, isHandoffBody, cacheGet, cacheSet, cacheClear };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The poll URL the server issued for each auth request started in this
|
|
8
|
+
* process, by key.
|
|
9
|
+
*
|
|
10
|
+
* A pending auth request lives on the core that created it, and its poll URL
|
|
11
|
+
* points at that core. The service's `access` URL may reach any core of a
|
|
12
|
+
* multi-core platform, so polling `access + key` can land on a core that does
|
|
13
|
+
* not know the request. Polling by key therefore uses the server-issued URL
|
|
14
|
+
* when this process started the request, and falls back to `access + key`
|
|
15
|
+
* only for a key it never saw.
|
|
16
|
+
*
|
|
17
|
+
* Keys are long random server values, so one process-wide map (like the
|
|
18
|
+
* hand-off cache) serves every Service.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Most entries kept (oldest dropped first), sized for a busy Node service. */
|
|
22
|
+
const MAX_ENTRIES = 1000;
|
|
23
|
+
/** How long an entry is kept: well beyond any auth request's lifetime. */
|
|
24
|
+
const TTL_MS = 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
/** key -> { pollUrl, expiresAt }, in insertion order */
|
|
27
|
+
const pollUrls = new Map();
|
|
28
|
+
|
|
29
|
+
/** Drop expired entries (called on every remember). */
|
|
30
|
+
function sweep () {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
for (const [key, entry] of pollUrls) {
|
|
33
|
+
if (now > entry.expiresAt) pollUrls.delete(key);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Remember the poll URL of an auth request (ignored unless both look valid).
|
|
39
|
+
* @param {string} key
|
|
40
|
+
* @param {string} pollUrl
|
|
41
|
+
*/
|
|
42
|
+
function remember (key, pollUrl) {
|
|
43
|
+
if (typeof key !== 'string' || key === '' || typeof pollUrl !== 'string' || !/^https?:\/\//.test(pollUrl)) return;
|
|
44
|
+
sweep();
|
|
45
|
+
pollUrls.delete(key);
|
|
46
|
+
pollUrls.set(key, { pollUrl, expiresAt: Date.now() + TTL_MS });
|
|
47
|
+
while (pollUrls.size > MAX_ENTRIES) pollUrls.delete(pollUrls.keys().next().value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The server-issued poll URL of `key`, or null when this process did not
|
|
52
|
+
* start that request (or it expired).
|
|
53
|
+
* @param {string} key
|
|
54
|
+
* @returns {string|null}
|
|
55
|
+
*/
|
|
56
|
+
function lookup (key) {
|
|
57
|
+
const entry = pollUrls.get(key);
|
|
58
|
+
if (entry == null) return null;
|
|
59
|
+
if (Date.now() > entry.expiresAt) {
|
|
60
|
+
pollUrls.delete(key);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return entry.pollUrl;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Forget every entry. */
|
|
67
|
+
function clear () {
|
|
68
|
+
pollUrls.clear();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { remember, lookup, clear, MAX_ENTRIES, TTL_MS };
|