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/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
- throw new PryvError(
207
- 'Invalid login response: ' + JSON.stringify(body)
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|boolean} [authRequest.returnUrl]
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 is built from
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 include a different subdomain).
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` + `body.token` are set.
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
- const serviceInfo = await this.info();
554
- pollUrl = serviceInfo.access + keyOrPollUrl;
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 `<access>/<key>` once; the call MUST be
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'
@@ -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;
@@ -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
- err.id = parsed?.error?.id;
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
- name: 'PryvError';
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; throws a
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
- returnUrl?: string | null;
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
- returnURL?: string;
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
- returnUrl?: string | boolean;
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
- export type StoredAuthorizationData = {
1194
+ /** A remembered account as stored (credentials included). */
1195
+ export type StoredAuthProfile = AuthProfile & {
1084
1196
  apiEndpoint: string;
1085
- username: string;
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
- startAuthRequest(): Promise<AuthRequestResponse>;
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 };