pryv 3.12.1 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.d.ts CHANGED
@@ -684,6 +684,19 @@ declare module 'pryv' {
684
684
  export type AccessInfo = Access & {
685
685
  calls: KeyValue;
686
686
  user: KeyValue;
687
+ /**
688
+ * Present when the access works through account delegation: a delegate
689
+ * token or an access granted through it (`isDelegatedAccess`, with
690
+ * `grantedVia: 'app'` for an app access granted by the delegate), or the
691
+ * control access of a delegation (`kind: 'control'`).
692
+ */
693
+ delegation?: {
694
+ isDelegatedAccess?: true;
695
+ kind?: 'control';
696
+ controlledUsername: string;
697
+ delegate: { username: string; hostSlug?: string };
698
+ grantedVia?: 'app';
699
+ };
687
700
  }
688
701
 
689
702
  export type EventAPICallRes = {
@@ -793,12 +806,16 @@ declare module 'pryv' {
793
806
  terms: string;
794
807
  eventTypes: string;
795
808
  version?: string;
809
+ /** Root URL of the platform's account app, when the platform names one. */
810
+ account?: string;
796
811
  assets?: {
797
812
  definitions: string;
798
813
  };
799
814
  serial?: string;
800
815
  features?: {
801
816
  noHF?: boolean;
817
+ /** Account delegation is available on this platform. */
818
+ delegation?: boolean;
802
819
  [key: string]: any;
803
820
  };
804
821
  };
@@ -964,7 +981,17 @@ declare module 'pryv' {
964
981
  | 'NEED_SIGNIN'
965
982
  | 'ACCEPTED'
966
983
  | 'SIGNOUT'
967
- | 'REFUSED';
984
+ | 'REFUSED'
985
+ | 'SWITCHING';
986
+
987
+ /**
988
+ * An account remembered by the sign-in button. `actingAs` marks an account
989
+ * used through account delegation (`delegate`: the person acting).
990
+ */
991
+ export type AuthProfile = {
992
+ username: string;
993
+ actingAs?: { username: string; delegate: string };
994
+ };
968
995
 
969
996
  export type StateChangeTypes = {
970
997
  ERROR: {
@@ -998,9 +1025,31 @@ declare module 'pryv' {
998
1025
  serviceInfo?: ServiceInfo;
999
1026
  apiEndpoint: string;
1000
1027
  username: string;
1028
+ /** Present on inline delivery; absent when a one-time `handoff` key is used. */
1001
1029
  token?: string;
1030
+ /**
1031
+ * Key of the auth request that just completed (use it with
1032
+ * `connectFromKey`). Absent when the account comes from stored
1033
+ * credentials: page load, an account switch without sign-in, or the
1034
+ * return to the previous account after a switch that did not complete.
1035
+ */
1036
+ key?: string;
1037
+ /** Display hint posted by the auth page for a grant on a controlled account; `accessInfo().delegation` is authoritative. */
1038
+ delegation?: {
1039
+ isDelegatedAccess: true;
1040
+ controlledUsername: string;
1041
+ delegate: { username: string; hostSlug?: string };
1042
+ };
1043
+ profile?: AuthProfile;
1044
+ /** One-time credential hand-off key (shared-secret delivery), in place of `token`. */
1045
+ handoff?: { type: 'shared-secret'; key: string };
1002
1046
  };
1003
1047
  SIGNOUT: {};
1048
+ SWITCHING: {
1049
+ from: string | null;
1050
+ /** null: the account is chosen in the sign-in popup */
1051
+ to: string | null;
1052
+ };
1004
1053
  REFUSED: {
1005
1054
  reasonID?: string;
1006
1055
  message?: string;
@@ -1034,10 +1083,32 @@ declare module 'pryv' {
1034
1083
  serviceInfo?: ServiceInfo;
1035
1084
  };
1036
1085
 
1086
+ /**
1087
+ * Account menu entries of the sign-in button. `false` on an entry, or its
1088
+ * name in `hide`, hides it.
1089
+ */
1090
+ export type LoginButtonMenuSettings = {
1091
+ logout?: boolean;
1092
+ account?: boolean;
1093
+ info?: boolean;
1094
+ /** Account switcher (remembered accounts, "Another account...", "Switch back"). */
1095
+ switch?: boolean;
1096
+ hide?: Array<'logout' | 'account' | 'info' | 'switch'>;
1097
+ };
1098
+
1037
1099
  export type AuthSettings = {
1038
1100
  spanButtonID?: string;
1039
1101
  onStateChange?: (state: StateChange<States>) => void;
1040
1102
  returnURL?: string;
1103
+ /**
1104
+ * Account menu shown when the signed-in button is clicked (default: on).
1105
+ * `false` restores the plain logout confirmation.
1106
+ */
1107
+ menu?: LoginButtonMenuSettings | false;
1108
+ /** Root URL of the account app; overrides the service's `account`. */
1109
+ accountUrl?: string;
1110
+ /** Most accounts remembered for this app (default 5); the least recently used is forgotten first. */
1111
+ maxProfiles?: number;
1041
1112
  authRequest: {
1042
1113
  requestingAppId: string;
1043
1114
  languageCode?: string;
@@ -1048,7 +1119,20 @@ declare module 'pryv' {
1048
1119
  clientData?: KeyValue;
1049
1120
  deviceName?: string;
1050
1121
  expireAfter?: number;
1122
+ /**
1123
+ * Credential delivery mode. Defaults to `'shared-secret'`: the token is
1124
+ * delivered through a one-time secret instead of the ACCEPTED poll, and
1125
+ * `connectFromKey` redeems it. `'inline'` opts out (no field sent, legacy
1126
+ * inline delivery). An older core ignores the field and delivers inline.
1127
+ */
1128
+ credentialHandoff?: 'shared-secret' | 'inline';
1051
1129
  serviceInfo?: Partial<ServiceInfo>;
1130
+ /**
1131
+ * Whether the sign-in may grant the access for an account the user
1132
+ * controls through account delegation: 'allow' (server default),
1133
+ * 'deny', or the username to preselect.
1134
+ */
1135
+ actAs?: 'allow' | 'deny' | string;
1052
1136
  };
1053
1137
  };
1054
1138
 
@@ -1072,6 +1156,7 @@ declare module 'pryv' {
1072
1156
  AUTHORIZED: 'ACCEPTED';
1073
1157
  SIGNOUT: 'SIGNOUT';
1074
1158
  REFUSED: 'REFUSED';
1159
+ SWITCHING: 'SWITCHING';
1075
1160
  };
1076
1161
 
1077
1162
  type AuthStatePayload = {
@@ -1080,9 +1165,25 @@ declare module 'pryv' {
1080
1165
  error?: Error | unknown;
1081
1166
  };
1082
1167
 
1083
- export type StoredAuthorizationData = {
1168
+ /** A remembered account as stored (credentials included). */
1169
+ export type StoredAuthProfile = AuthProfile & {
1084
1170
  apiEndpoint: string;
1085
- username: string;
1171
+ unavailable?: true;
1172
+ };
1173
+
1174
+ /**
1175
+ * Stored sign-in data. The top-level `apiEndpoint` / `username` are the
1176
+ * active account (absent after logging out of one of several accounts);
1177
+ * `profiles` lists every remembered account, most recently used first.
1178
+ * The default button stores the two parts in two cookies.
1179
+ */
1180
+ export type StoredAuthorizationData = {
1181
+ apiEndpoint?: string;
1182
+ username?: string;
1183
+ actingAs?: AuthProfile['actingAs'];
1184
+ /** Auth page of the sign-in (without query), kept to locate the account app. */
1185
+ authUrl?: string;
1186
+ profiles?: StoredAuthProfile[];
1086
1187
  } | null;
1087
1188
 
1088
1189
  export type CustomLoginButton = {
@@ -1093,6 +1194,12 @@ declare module 'pryv' {
1093
1194
  saveAuthorizationData?: (authData: StoredAuthorizationData) => void;
1094
1195
  deleteAuthorizationData?: () => Promise<void>;
1095
1196
  finishAuthProcessAfterRedirection?: (authController: AuthController) => Promise<void>;
1197
+ /**
1198
+ * Called when the signed-in button is clicked. Return `false` to fall
1199
+ * back to the SIGNOUT state (logout confirmation); log out from the menu
1200
+ * with `AuthController.signOut()`.
1201
+ */
1202
+ showMenu?: () => boolean | void;
1096
1203
  };
1097
1204
 
1098
1205
  export class LoginButton implements CustomLoginButton {
@@ -1115,6 +1222,11 @@ declare module 'pryv' {
1115
1222
  saveAuthorizationData(authData: StoredAuthorizationData): void;
1116
1223
  deleteAuthorizationData(): Promise<void>;
1117
1224
  finishAuthProcessAfterRedirection(authController: AuthController): Promise<void>;
1225
+ showMenu(): boolean;
1226
+ /** Resolves when the re-initialization it may start (a dismissed "Log out?") is done. */
1227
+ closeMenu(): Promise<void>;
1228
+ /** The re-initialization started by the last menu action, if any. */
1229
+ pending?: Promise<void>;
1118
1230
  }
1119
1231
 
1120
1232
  export class AuthController {
@@ -1140,7 +1252,30 @@ declare module 'pryv' {
1140
1252
  windowLocationForTest?: string,
1141
1253
  navigatorForTests?: string,
1142
1254
  ): string | boolean;
1143
- startAuthRequest(): Promise<AuthRequestResponse>;
1255
+ /** `overrides`: auth request fields for this request only; `previous`: state to return to if an account switch does not complete. */
1256
+ startAuthRequest(overrides?: Partial<AuthSettings['authRequest']>, previous?: AuthStatePayload): Promise<AuthRequestResponse>;
1257
+ /**
1258
+ * Log out: emits SIGNOUT once, forgets the active account (every
1259
+ * remembered account with `all`), re-initializes.
1260
+ */
1261
+ signOut(options?: { all?: boolean }): Promise<void>;
1262
+ /** The remembered accounts, most recently used first. */
1263
+ profiles(): Array<AuthProfile & { active: boolean; available: boolean }>;
1264
+ /** The signed-in account, or null. */
1265
+ currentProfile(): AuthProfile | null;
1266
+ /**
1267
+ * Switch account (`null`: the signed-in person's own account). A
1268
+ * remembered account with a valid access needs no sign-in; otherwise the
1269
+ * auth request runs again with `actAs`. Emits SWITCHING, ends in ACCEPTED,
1270
+ * or back on the previous account when the sign-in is refused.
1271
+ */
1272
+ switchTo(username: string | null): Promise<void>;
1273
+ /** Sign in to one more account (`actAs: 'allow'`), keeping the remembered ones. */
1274
+ addAccount(): Promise<void>;
1275
+ /** Account app profile URL, or null when it cannot be determined. */
1276
+ accountUrl(): string | null;
1277
+ /** Opens the account app in a new tab; returns the URL, or null. */
1278
+ openAccountApp(): string | null;
1144
1279
  set state(newState: AuthStatePayload);
1145
1280
  get state(): AuthStatePayload;
1146
1281
  }
@@ -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 };
@@ -59,6 +59,20 @@ describe('[ACNX] AuthController', function () {
59
59
  authRequest: { requestingAppId: 'test-app' }
60
60
  }, service)).to.throw('Missing settings.authRequest.requestedPermissions');
61
61
  });
62
+
63
+ it('[ACVE] defaults the request to shared-secret credential hand-off', function () {
64
+ const settings = { authRequest: { requestingAppId: 'test-app', requestedPermissions: [] } };
65
+ // eslint-disable-next-line no-new
66
+ new AuthController(settings, service);
67
+ expect(settings.authRequest.credentialHandoff).to.equal('shared-secret');
68
+ });
69
+
70
+ it('[ACVF] credentialHandoff:\'inline\' opts out and sends no field', function () {
71
+ const settings = { authRequest: { requestingAppId: 'test-app', requestedPermissions: [], credentialHandoff: 'inline' } };
72
+ // eslint-disable-next-line no-new
73
+ new AuthController(settings, service);
74
+ expect(settings.authRequest).to.not.have.property('credentialHandoff');
75
+ });
62
76
  });
63
77
 
64
78
  describe('[ACLX] Listeners', function () {
@@ -133,6 +147,125 @@ describe('[ACNX] AuthController', function () {
133
147
  });
134
148
  });
135
149
 
150
+ describe('[ACUX] account app URL and signOut', function () {
151
+ function controller (extraSettings) {
152
+ return new AuthController(Object.assign({
153
+ authRequest: { requestingAppId: 'test-app', requestedPermissions: [] }
154
+ }, extraSettings), service);
155
+ }
156
+ const profile = (base) => base + '/account/profile?pryvServiceInfoUrl=' + encodeURIComponent(testData.serviceInfoUrl);
157
+
158
+ it('[ACUA] resolves settings.accountUrl, then the service account, then the auth page URL', async function () {
159
+ const auth = controller({ accountUrl: 'https://own.example.com/' });
160
+ await auth.init();
161
+ auth.serviceInfo = Object.assign({}, auth.serviceInfo, { account: 'https://account.example.com' });
162
+ auth._authUrl = 'https://ui.example.com/auth?key=abc';
163
+ expect(auth.accountUrl()).to.equal(profile('https://own.example.com'));
164
+ delete auth.settings.accountUrl;
165
+ expect(auth.accountUrl()).to.equal(profile('https://account.example.com'));
166
+ delete auth.serviceInfo.account;
167
+ expect(auth.accountUrl()).to.equal(profile('https://ui.example.com'));
168
+ });
169
+
170
+ it('[ACUB] is null when nothing names the account app', async function () {
171
+ const auth = controller();
172
+ await auth.init();
173
+ auth.serviceInfo = Object.assign({}, auth.serviceInfo);
174
+ delete auth.serviceInfo.account;
175
+ expect(auth.accountUrl()).to.equal(null);
176
+ for (const authUrl of ['https://ui.example.com/access/login?key=x', 'not a url', 'https://ui.example.com/authorize']) {
177
+ auth._authUrl = authUrl;
178
+ expect(auth.accountUrl(), authUrl).to.equal(null);
179
+ }
180
+ });
181
+
182
+ it('[ACUC] openAccountApp opens the profile page in a new tab without an opener', async function () {
183
+ const auth = controller({ accountUrl: 'https://own.example.com' });
184
+ await auth.init();
185
+ const opened = [];
186
+ const original = window.open;
187
+ window.open = (...args) => { opened.push(args); return null; };
188
+ try {
189
+ expect(auth.openAccountApp()).to.equal(profile('https://own.example.com'));
190
+ } finally { window.open = original; }
191
+ expect(opened).to.deep.equal([[profile('https://own.example.com'), '_blank', 'noopener']]);
192
+ });
193
+
194
+ it('[ACUD] signOut emits SIGNOUT once, clears the stored credentials and re-initializes', async function () {
195
+ const states = [];
196
+ let deleted = 0;
197
+ const button = {
198
+ getAuthorizationData: () => null,
199
+ deleteAuthorizationData: async () => { deleted++; },
200
+ onStateChange: async () => {},
201
+ onClick: () => {}
202
+ };
203
+ const auth = new AuthController({
204
+ authRequest: { requestingAppId: 'test-app', requestedPermissions: [] },
205
+ onStateChange: (s) => states.push(s.status)
206
+ }, service, button);
207
+ await auth.init();
208
+ auth._state = { status: AuthStates.AUTHORIZED, username: 'u' };
209
+ states.length = 0;
210
+ await auth.signOut();
211
+ expect(states.filter((s) => s === AuthStates.SIGNOUT)).to.have.lengthOf(1);
212
+ expect(deleted).to.equal(1);
213
+ expect(auth.state.status).to.equal(AuthStates.INITIALIZED);
214
+ });
215
+
216
+ it('[ACUF] the auth page URL of a sign-in locates the account app, and survives a reload', async function () {
217
+ const utils = require('../src/utils');
218
+ const { fetchPost, fetchGet } = utils;
219
+ utils.fetchPost = async () => ({ response: { ok: true }, body: { status: AuthStates.NEED_SIGNIN, key: 'k1', poll: 'https://core.example.com/reg/access/k1', poll_rate_ms: 1, authUrl: 'https://ui.example.com/auth?key=k1' } });
220
+ utils.fetchGet = async () => ({ response: { status: 200 }, body: { status: AuthStates.AUTHORIZED, username: 'u', token: 't', apiEndpoint: 'https://t@u.example.com/' } });
221
+ let saved = null;
222
+ const button = {
223
+ getAuthorizationData: () => saved,
224
+ saveAuthorizationData: (d) => { saved = d; },
225
+ onStateChange: async (state) => {
226
+ if (state.status === AuthStates.AUTHORIZED) saved = { apiEndpoint: state.apiEndpoint, username: state.username, authUrl: auth._authUrl };
227
+ },
228
+ onClick: () => {}
229
+ };
230
+ const auth = new AuthController({ authRequest: { requestingAppId: 'test-app', requestedPermissions: [] } }, service, button);
231
+ try {
232
+ await auth.init();
233
+ auth.serviceInfo = Object.assign({}, auth.serviceInfo, { account: undefined });
234
+ await auth.startAuthRequest();
235
+ } finally { utils.fetchPost = fetchPost; utils.fetchGet = fetchGet; }
236
+ expect(auth.accountUrl()).to.equal(profile('https://ui.example.com'));
237
+
238
+ // a new page load: only the stored data is known
239
+ const reloaded = new AuthController({ authRequest: { requestingAppId: 'test-app', requestedPermissions: [] } }, service, button);
240
+ await reloaded.init();
241
+ reloaded.serviceInfo = Object.assign({}, reloaded.serviceInfo, { account: undefined });
242
+ expect(reloaded.state.status).to.equal(AuthStates.AUTHORIZED);
243
+ expect(reloaded.accountUrl()).to.equal(profile('https://ui.example.com'));
244
+ });
245
+
246
+ it('[ACUE] a button with showMenu gets the click instead of SIGNOUT, unless it declines', async function () {
247
+ for (const [answer, expectSignout] of [[true, false], [undefined, false], [false, true]]) {
248
+ const states = [];
249
+ let menus = 0;
250
+ const button = {
251
+ getAuthorizationData: () => null,
252
+ onStateChange: async () => {},
253
+ onClick: () => {},
254
+ showMenu: () => { menus++; return answer; }
255
+ };
256
+ const auth = new AuthController({
257
+ authRequest: { requestingAppId: 'test-app', requestedPermissions: [] },
258
+ onStateChange: (s) => states.push(s.status)
259
+ }, service, button);
260
+ await auth.init();
261
+ auth._state = { status: AuthStates.AUTHORIZED };
262
+ await auth.handleClick();
263
+ expect(menus).to.equal(1);
264
+ expect(states.includes(AuthStates.SIGNOUT), 'showMenu returned ' + answer).to.equal(expectSignout);
265
+ }
266
+ });
267
+ });
268
+
136
269
  describe('[ACSX] stopAuthRequest', function () {
137
270
  it('[ACSA] sets error state with message', async function () {
138
271
  const auth = new AuthController({
@@ -264,4 +397,81 @@ describe('[ACNX] AuthController', function () {
264
397
  expect(last.message).to.equal('boom');
265
398
  });
266
399
  });
400
+
401
+ describe('[ACGX] credential hand-off polling', function () {
402
+ let realFetch;
403
+ let handoffKeyCounter = 0;
404
+
405
+ /** Stub the whole flow: access POST -> NEED_SIGNIN, poll GET -> pollBody,
406
+ * shared-secrets/retrieve -> retrieveResponse. */
407
+ function stubFlow (pollBody, retrieveResponse) {
408
+ realFetch = global.fetch;
409
+ global.fetch = async function (url, options) {
410
+ const u = String(url);
411
+ if (u.includes('shared-secrets/retrieve')) {
412
+ return {
413
+ ok: retrieveResponse.ok !== false,
414
+ status: retrieveResponse.status || 200,
415
+ json: async () => retrieveResponse.body
416
+ };
417
+ }
418
+ if (options && options.method === 'POST' && u.includes('/access')) {
419
+ return {
420
+ ok: true,
421
+ status: 201,
422
+ json: async () => ({
423
+ status: 'NEED_SIGNIN',
424
+ key: 'ho-key-' + (++handoffKeyCounter),
425
+ poll: 'https://reg.test.local/access/pk' + handoffKeyCounter,
426
+ poll_rate_ms: 5
427
+ })
428
+ };
429
+ }
430
+ if (u.includes('/access/')) { // the poll GET
431
+ return { ok: true, status: 200, json: async () => pollBody };
432
+ }
433
+ return { ok: true, status: 200, json: async () => ({}) };
434
+ };
435
+ }
436
+
437
+ afterEach(function () {
438
+ if (realFetch) global.fetch = realFetch;
439
+ realFetch = null;
440
+ });
441
+
442
+ function makeAuth () {
443
+ const auth = new AuthController({
444
+ authRequest: { requestingAppId: 'test-app', requestedPermissions: [] }
445
+ }, service);
446
+ // Skip init() (asset loading): set the service info the flow needs.
447
+ auth.serviceInfo = { access: 'https://reg.test.local/access', register: 'https://reg.test.local/' };
448
+ return auth;
449
+ }
450
+
451
+ it('[ACGA] redeems a hand-off ACCEPTED body and rewrites the internal state to the legacy shape (token, no handoff)', async function () {
452
+ stubFlow(
453
+ { status: 'ACCEPTED', username: 'eve', apiEndpoint: 'https://eve.test.local/', handoff: { type: 'shared-secret', key: 'evt.' + 'e'.repeat(40) } },
454
+ { body: { secret: { username: 'eve', token: 'tok-eve', apiEndpoint: 'https://eve.test.local/' } } }
455
+ );
456
+ const auth = makeAuth();
457
+ await auth.startAuthRequest();
458
+ expect(auth.state.status).to.equal(AuthStates.AUTHORIZED);
459
+ expect(auth.state.token).to.equal('tok-eve');
460
+ expect(auth.state.apiEndpoint).to.contain('tok-eve@');
461
+ expect(auth.state.apiEndpoint).to.contain('eve.test.local');
462
+ // The one-time key must not survive into the state the cookie/listener see.
463
+ expect(auth.state.handoff).to.equal(undefined);
464
+ });
465
+
466
+ it('[ACGB] a failed hand-off retrieve puts the controller in ERROR, not a broken AUTHORIZED', async function () {
467
+ stubFlow(
468
+ { status: 'ACCEPTED', username: 'frank', apiEndpoint: 'https://frank.test.local/', handoff: { type: 'shared-secret', key: 'evt.' + 'f'.repeat(40) } },
469
+ { ok: false, status: 403, body: { error: { id: 'forbidden', message: 'used', data: { id: 'shared-secret-unavailable' } } } }
470
+ );
471
+ const auth = makeAuth();
472
+ await auth.startAuthRequest();
473
+ expect(auth.state.status).to.equal(AuthStates.ERROR);
474
+ expect(auth.state.token).to.equal(undefined);
475
+ });
476
+ });
267
477
  });