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
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
|
-
|
|
1168
|
+
/** A remembered account as stored (credentials included). */
|
|
1169
|
+
export type StoredAuthProfile = AuthProfile & {
|
|
1084
1170
|
apiEndpoint: string;
|
|
1085
|
-
|
|
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
|
-
|
|
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
|
});
|
|
@@ -112,9 +112,10 @@ describe('[AUTX] Browser.LoginButton', function () {
|
|
|
112
112
|
const seen = [];
|
|
113
113
|
const a = makeAuth((s) => seen.push(s.status));
|
|
114
114
|
await a.init();
|
|
115
|
-
//
|
|
116
|
-
// SIGNOUT it
|
|
117
|
-
// to INITIALIZED during the same dispatch.
|
|
115
|
+
// Worst case of a LoginButton-like listener (registered after the external
|
|
116
|
+
// app listener): on SIGNOUT it re-initializes synchronously, driving the
|
|
117
|
+
// state to INITIALIZED during the same dispatch. (The real LoginButton
|
|
118
|
+
// awaits its re-init, which starts a microtask later.)
|
|
118
119
|
a.stateChangeListeners.push((s) => {
|
|
119
120
|
if (s.status === AuthStates.SIGNOUT) a.state = { status: AuthStates.INITIALIZED, serviceInfo: {} };
|
|
120
121
|
});
|