@structured-id/ui-core 1.3.0 → 3.0.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 +22 -10
- package/package.json +25 -43
- package/src/components/SidStatusBadge.vue +1 -5
- package/src/composables/useAccountTransport.test.ts +2 -1
- package/src/composables/useGrpc.test.ts +17 -14
- package/src/composables/useGrpc.ts +4 -2
- package/src/index.ts +2 -120
- package/src/profile/components/ConsentManager.vue +5 -1
- package/src/profile/components/ProfileEditor.vue +1 -1
- package/src/profile/components/SecuritySettings.vue +5 -1
- package/src/profile/components/SessionList.vue +2 -1
- package/src/profile/components/SidRegistrationForm.test.ts +173 -1
- package/src/profile/components/SidRegistrationForm.vue +88 -32
- package/src/profile/components/index.ts +1 -4
- package/src/profile/composables/index.ts +1 -0
- package/src/profile/composables/operationPlan.test.ts +155 -0
- package/src/profile/composables/operationPlan.ts +218 -0
- package/src/profile/composables/useAccountApi.ts +4 -4
- package/src/profile/composables/useAuth.test.ts +312 -31
- package/src/profile/composables/useAuth.ts +176 -30
- package/src/profile/composables/useAuthApi.opaque.test.ts +13 -6
- package/src/profile/composables/useAuthApi.ts +40 -18
- package/src/profile/composables/useCurrentPasswordRequirement.test.ts +196 -0
- package/src/profile/composables/useCurrentPasswordRequirement.ts +95 -0
- package/src/profile/composables/useEnrollmentApi.test.ts +7 -3
- package/src/profile/composables/useEnrollmentApi.ts +3 -5
- package/src/profile/composables/useIdentityApi.ts +4 -4
- package/src/profile/composables/useSecurityLevel.test.ts +1 -1
- package/src/profile/composables/useSecurityLevel.ts +1 -1
- package/src/quasar/SidPasswordCeremony.test.ts +253 -0
- package/src/quasar/SidPasswordCeremony.vue +456 -0
- package/src/quasar/index.ts +2 -0
- package/src/utils/rpcRefusal.test.ts +4 -4
- package/src/utils/rpcRefusal.ts +8 -4
- package/dist/browser-vp/index.cjs +0 -349
- package/dist/browser-vp/index.d.cts +0 -276
- package/dist/browser-vp/index.d.ts +0 -276
- package/dist/browser-vp/index.js +0 -349
- package/dist/chunk-C3Q766CY.cjs +0 -125
- package/dist/chunk-J5YXRXB5.js +0 -125
- package/dist/index.cjs +0 -23581
- package/dist/index.d.cts +0 -11539
- package/dist/index.d.ts +0 -11539
- package/dist/index.js +0 -23581
- package/dist/storage-4CB2JTLW.cjs +0 -22
- package/dist/storage-THRWREZP.js +0 -22
- package/src/browser-vp/crypto.ts +0 -253
- package/src/browser-vp/index.test.ts +0 -10
- package/src/browser-vp/index.ts +0 -116
- package/src/browser-vp/prf.ts +0 -179
- package/src/browser-vp/storage.test.ts +0 -9
- package/src/browser-vp/storage.ts +0 -163
- package/src/browser-vp/types.ts +0 -95
- package/src/browser-vp/vp.test.ts +0 -43
- package/src/browser-vp/vp.ts +0 -111
- package/src/generated/google/api/http.ts +0 -687
- package/src/generated/google/api/httpbody.ts +0 -159
- package/src/generated/google/protobuf/any.ts +0 -326
- package/src/generated/google/protobuf/descriptor.ts +0 -4865
- package/src/generated/google/protobuf/duration.ts +0 -230
- package/src/generated/google/protobuf/empty.ts +0 -94
- package/src/generated/google/protobuf/struct.ts +0 -484
- package/src/generated/google/protobuf/timestamp.ts +0 -287
- package/src/generated/google/rpc/error_details.ts +0 -1386
- package/src/generated/google/rpc/status.ts +0 -127
- package/src/generated/sid/v1/account/account.client.ts +0 -527
- package/src/generated/sid/v1/account/account.ts +0 -3261
- package/src/generated/sid/v1/admin/admin.client.ts +0 -139
- package/src/generated/sid/v1/admin/admin.ts +0 -781
- package/src/generated/sid/v1/admin/admin_roles.client.ts +0 -181
- package/src/generated/sid/v1/admin/admin_roles.ts +0 -1606
- package/src/generated/sid/v1/admin/analytics.client.ts +0 -106
- package/src/generated/sid/v1/admin/analytics.ts +0 -1017
- package/src/generated/sid/v1/admin/branding.client.ts +0 -166
- package/src/generated/sid/v1/admin/branding.ts +0 -1906
- package/src/generated/sid/v1/admin/enrollment.client.ts +0 -177
- package/src/generated/sid/v1/admin/enrollment.ts +0 -1766
- package/src/generated/sid/v1/admin/events.client.ts +0 -195
- package/src/generated/sid/v1/admin/events.ts +0 -1900
- package/src/generated/sid/v1/admin/flow.client.ts +0 -183
- package/src/generated/sid/v1/admin/flow.ts +0 -1552
- package/src/generated/sid/v1/admin/keys.client.ts +0 -107
- package/src/generated/sid/v1/admin/keys.ts +0 -931
- package/src/generated/sid/v1/admin/login_experience.client.ts +0 -160
- package/src/generated/sid/v1/admin/login_experience.ts +0 -1061
- package/src/generated/sid/v1/admin/migration.client.ts +0 -127
- package/src/generated/sid/v1/admin/migration.ts +0 -1948
- package/src/generated/sid/v1/admin/notifications.client.ts +0 -134
- package/src/generated/sid/v1/admin/notifications.ts +0 -1273
- package/src/generated/sid/v1/admin/operations.client.ts +0 -241
- package/src/generated/sid/v1/admin/operations.ts +0 -2633
- package/src/generated/sid/v1/admin/organizations.client.ts +0 -57
- package/src/generated/sid/v1/admin/organizations.ts +0 -332
- package/src/generated/sid/v1/admin/provisioning.client.ts +0 -232
- package/src/generated/sid/v1/admin/provisioning.ts +0 -1383
- package/src/generated/sid/v1/admin/scopes.client.ts +0 -171
- package/src/generated/sid/v1/admin/scopes.ts +0 -1324
- package/src/generated/sid/v1/admin/security.client.ts +0 -410
- package/src/generated/sid/v1/admin/security.ts +0 -4077
- package/src/generated/sid/v1/admin/sessions.client.ts +0 -114
- package/src/generated/sid/v1/admin/sessions.ts +0 -923
- package/src/generated/sid/v1/admin/settings.client.ts +0 -351
- package/src/generated/sid/v1/admin/settings.ts +0 -2458
- package/src/generated/sid/v1/admin/user_profile.client.ts +0 -57
- package/src/generated/sid/v1/admin/user_profile.ts +0 -730
- package/src/generated/sid/v1/admin/webhooks.client.ts +0 -269
- package/src/generated/sid/v1/admin/webhooks.ts +0 -2804
- package/src/generated/sid/v1/attestation/attestation.client.ts +0 -115
- package/src/generated/sid/v1/attestation/attestation.ts +0 -813
- package/src/generated/sid/v1/authn/auth.client.ts +0 -943
- package/src/generated/sid/v1/authn/auth.ts +0 -9719
- package/src/generated/sid/v1/authn/flows.client.ts +0 -413
- package/src/generated/sid/v1/authn/flows.ts +0 -3205
- package/src/generated/sid/v1/authn/issuer.client.ts +0 -489
- package/src/generated/sid/v1/authn/issuer.ts +0 -1600
- package/src/generated/sid/v1/authn/password_history.client.ts +0 -57
- package/src/generated/sid/v1/authn/password_history.ts +0 -578
- package/src/generated/sid/v1/authn/system_integration.client.ts +0 -73
- package/src/generated/sid/v1/authn/system_integration.ts +0 -218
- package/src/generated/sid/v1/authz/authz.client.ts +0 -459
- package/src/generated/sid/v1/authz/authz.ts +0 -6524
- package/src/generated/sid/v1/authz/forward_auth.client.ts +0 -97
- package/src/generated/sid/v1/authz/forward_auth.ts +0 -79
- package/src/generated/sid/v1/authz/governance.client.ts +0 -102
- package/src/generated/sid/v1/authz/governance.ts +0 -875
- package/src/generated/sid/v1/common/errors.ts +0 -710
- package/src/generated/sid/v1/common/pagination.ts +0 -152
- package/src/generated/sid/v1/common/transport.ts +0 -227
- package/src/generated/sid/v1/common/types.ts +0 -151
- package/src/generated/sid/v1/events/event_stream.client.ts +0 -67
- package/src/generated/sid/v1/events/event_stream.ts +0 -375
- package/src/generated/sid/v1/federation/upstream.client.ts +0 -142
- package/src/generated/sid/v1/federation/upstream.ts +0 -1736
- package/src/generated/sid/v1/identity/identity.client.ts +0 -467
- package/src/generated/sid/v1/identity/identity.ts +0 -5676
- package/src/generated/sid/v1/ids/ids.ts +0 -395
- package/src/generated/sid/v1/machine/machine_user.client.ts +0 -219
- package/src/generated/sid/v1/machine/machine_user.ts +0 -2364
- package/src/generated/sid/v1/machine/pat.client.ts +0 -130
- package/src/generated/sid/v1/machine/pat.ts +0 -1083
- package/src/generated/sid/v1/projects/projects.client.ts +0 -404
- package/src/generated/sid/v1/projects/projects.ts +0 -4446
- package/src/generated/sid/v1/scim/scim.client.ts +0 -520
- package/src/generated/sid/v1/scim/scim.ts +0 -3182
- package/src/generated/sid/v1/test/test.client.ts +0 -232
- package/src/generated/sid/v1/test/test.ts +0 -1136
- package/src/transport/adaptive.test.ts +0 -728
- package/src/transport/adaptive.ts +0 -308
- package/src/transport/framing.test.ts +0 -227
- package/src/transport/framing.ts +0 -98
- package/src/transport/webtransport.test.ts +0 -728
- package/src/transport/webtransport.ts +0 -323
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
</q-banner>
|
|
32
32
|
|
|
33
33
|
<q-banner
|
|
34
|
-
v-if="error"
|
|
34
|
+
v-if="error && !ceremony"
|
|
35
35
|
class="bg-negative text-white q-mb-md"
|
|
36
36
|
rounded
|
|
37
37
|
dense
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
</q-banner>
|
|
41
41
|
|
|
42
42
|
<q-banner
|
|
43
|
-
v-if="success"
|
|
43
|
+
v-if="success && !ceremony"
|
|
44
44
|
class="bg-positive text-white q-mb-md"
|
|
45
45
|
rounded
|
|
46
46
|
dense
|
|
@@ -48,21 +48,27 @@
|
|
|
48
48
|
{{ successText }}
|
|
49
49
|
</q-banner>
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
<!-- While the operation runs the ceremony takes the fields' place;
|
|
52
|
+
an application's own `progress` slot replaces the ceremony. -->
|
|
53
|
+
<template v-if="ceremony">
|
|
54
|
+
<slot name="progress" :progress="progress">
|
|
55
|
+
<sid-password-ceremony
|
|
56
|
+
:progress="progress"
|
|
57
|
+
:outcome="ceremony"
|
|
58
|
+
:fields="showConfirmPassword ? 2 : 1"
|
|
59
|
+
:length="password.length"
|
|
60
|
+
:accepted-text="acceptedText"
|
|
61
|
+
:refused-text="error ?? undefined"
|
|
62
|
+
@settled="onSettled"
|
|
57
63
|
/>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
{{ progress.label }}
|
|
61
|
-
</div>
|
|
62
|
-
</div>
|
|
63
|
-
</slot>
|
|
64
|
+
</slot>
|
|
65
|
+
</template>
|
|
64
66
|
|
|
65
|
-
<q-form
|
|
67
|
+
<q-form
|
|
68
|
+
v-show="!ceremony && !success"
|
|
69
|
+
@submit.prevent="onSubmit"
|
|
70
|
+
class="q-gutter-y-md"
|
|
71
|
+
>
|
|
66
72
|
<q-input
|
|
67
73
|
v-if="claimRequired"
|
|
68
74
|
v-model="claimToken"
|
|
@@ -207,8 +213,10 @@
|
|
|
207
213
|
</template>
|
|
208
214
|
|
|
209
215
|
<script setup lang="ts">
|
|
210
|
-
import { ref, computed, watch } from "vue";
|
|
211
|
-
import { SidPrincipalInput } from "../../quasar";
|
|
216
|
+
import { ref, computed, watch, useSlots } from "vue";
|
|
217
|
+
import { SidPasswordCeremony, SidPrincipalInput } from "../../quasar";
|
|
218
|
+
import type { CeremonyOutcome } from "../../quasar";
|
|
219
|
+
import type { PasswordOperationProgress } from "../composables/useAuth";
|
|
212
220
|
|
|
213
221
|
// Minimal QInput shape — we only use validate(). Avoids importing 'quasar' at
|
|
214
222
|
// type level (it's a peer dep, not present during ui-core typecheck).
|
|
@@ -226,13 +234,8 @@ export interface RegistrationExtras {
|
|
|
226
234
|
claimToken?: string;
|
|
227
235
|
}
|
|
228
236
|
|
|
229
|
-
/** Progress of the registration (the password proof takes seconds): 0..1 and a label. */
|
|
230
|
-
export interface RegistrationProgress {
|
|
231
|
-
fraction: number;
|
|
232
|
-
label: string;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
237
|
void SidPrincipalInput;
|
|
238
|
+
void SidPasswordCeremony;
|
|
236
239
|
|
|
237
240
|
const props = withDefaults(
|
|
238
241
|
defineProps<{
|
|
@@ -295,8 +298,10 @@ const props = withDefaults(
|
|
|
295
298
|
identifier: string,
|
|
296
299
|
password: string,
|
|
297
300
|
extras: RegistrationExtras,
|
|
298
|
-
onProgress?: (p:
|
|
301
|
+
onProgress?: (p: PasswordOperationProgress) => void,
|
|
299
302
|
) => Promise<void>;
|
|
303
|
+
/** What the ceremony says once the account is created. */
|
|
304
|
+
acceptedText?: string;
|
|
300
305
|
/** Min password length (default 12 — NIST SP 800-63B 4th draft baseline). */
|
|
301
306
|
passwordMinLength?: number;
|
|
302
307
|
/** Show confirm-password field with match validation. Default true. */
|
|
@@ -336,6 +341,7 @@ const props = withDefaults(
|
|
|
336
341
|
"This installation has no administrator yet. Enter the claim token from the service log; the account you create becomes its administrator.",
|
|
337
342
|
showConfirmPassword: true,
|
|
338
343
|
usernameMinLength: 6,
|
|
344
|
+
acceptedText: "Account created",
|
|
339
345
|
},
|
|
340
346
|
);
|
|
341
347
|
|
|
@@ -344,6 +350,18 @@ const emit = defineEmits<{
|
|
|
344
350
|
error: [error: Error];
|
|
345
351
|
}>();
|
|
346
352
|
|
|
353
|
+
defineSlots<{
|
|
354
|
+
header?: () => unknown;
|
|
355
|
+
/** Replaces the ceremony while the operation runs; the form closes when it ends. */
|
|
356
|
+
progress?: (props: { progress: PasswordOperationProgress | null }) => unknown;
|
|
357
|
+
"extra-fields"?: () => unknown;
|
|
358
|
+
links?: () => unknown;
|
|
359
|
+
footer?: () => unknown;
|
|
360
|
+
}>();
|
|
361
|
+
const slots = useSlots();
|
|
362
|
+
/** Longest a `progress` slot holds the final report when no frame paints. */
|
|
363
|
+
const PAINT_FALLBACK_MS = 250;
|
|
364
|
+
|
|
347
365
|
const identifier = ref("");
|
|
348
366
|
const principalType = ref<PrincipalType>("unknown");
|
|
349
367
|
const password = ref("");
|
|
@@ -352,7 +370,11 @@ const showPassword = ref(false);
|
|
|
352
370
|
const loading = ref(false);
|
|
353
371
|
const error = ref<string | null>(null);
|
|
354
372
|
const success = ref(false);
|
|
355
|
-
const progress = ref<
|
|
373
|
+
const progress = ref<PasswordOperationProgress | null>(null);
|
|
374
|
+
/** The running or closing ceremony; null while the fields are shown. */
|
|
375
|
+
const ceremony = ref<CeremonyOutcome | null>(null);
|
|
376
|
+
/** What the form announces once the ceremony has closed. */
|
|
377
|
+
let announce: (() => void) | undefined;
|
|
356
378
|
|
|
357
379
|
const passwordRef = ref<ValidatableInput | null>(null);
|
|
358
380
|
const confirmRef = ref<ValidatableInput | null>(null);
|
|
@@ -397,6 +419,10 @@ async function onSubmit() {
|
|
|
397
419
|
|
|
398
420
|
loading.value = true;
|
|
399
421
|
error.value = null;
|
|
422
|
+
progress.value = null;
|
|
423
|
+
ceremony.value = "running";
|
|
424
|
+
// A phone's keyboard would cover the ceremony.
|
|
425
|
+
(document.activeElement as HTMLElement | null)?.blur?.();
|
|
400
426
|
|
|
401
427
|
try {
|
|
402
428
|
const normalized = normalizePrincipal(
|
|
@@ -409,19 +435,49 @@ async function onSubmit() {
|
|
|
409
435
|
await props.registerFn(normalized.value, password.value, extras, (p) => {
|
|
410
436
|
progress.value = p;
|
|
411
437
|
});
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
438
|
+
announce = () => {
|
|
439
|
+
success.value = true;
|
|
440
|
+
emit("success", {
|
|
441
|
+
identifier: normalized.value,
|
|
442
|
+
principalType: normalized.type,
|
|
443
|
+
});
|
|
444
|
+
};
|
|
445
|
+
ceremony.value = "accepted";
|
|
417
446
|
} catch (e) {
|
|
418
447
|
success.value = false;
|
|
419
448
|
const err = e instanceof Error ? e : new Error("Registration failed");
|
|
420
449
|
error.value = refusalMessage(e, "Registration failed");
|
|
421
|
-
emit("error", err);
|
|
450
|
+
announce = () => emit("error", err);
|
|
451
|
+
ceremony.value = "refused";
|
|
422
452
|
} finally {
|
|
423
453
|
loading.value = false;
|
|
424
|
-
progress.value = null;
|
|
425
454
|
}
|
|
455
|
+
// A `progress` slot has no closing animation to wait for, but the final
|
|
456
|
+
// report came in this same turn: the slot keeps it for one painted frame
|
|
457
|
+
// (the second animation frame runs after the first has been painted). A
|
|
458
|
+
// background tab may run no frames at all, so a timer settles it then;
|
|
459
|
+
// whichever comes first settles, once.
|
|
460
|
+
if (slots.progress) {
|
|
461
|
+
let settled = false;
|
|
462
|
+
const settle = () => {
|
|
463
|
+
if (settled) return;
|
|
464
|
+
settled = true;
|
|
465
|
+
clearTimeout(fallback);
|
|
466
|
+
onSettled();
|
|
467
|
+
};
|
|
468
|
+
const fallback = setTimeout(settle, PAINT_FALLBACK_MS);
|
|
469
|
+
requestAnimationFrame(() => requestAnimationFrame(settle));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The ceremony closed and gives way: a refusal to the fields with its
|
|
475
|
+
* reason, an acceptance to the success text. Then the result is told.
|
|
476
|
+
*/
|
|
477
|
+
function onSettled() {
|
|
478
|
+
ceremony.value = null;
|
|
479
|
+
progress.value = null;
|
|
480
|
+
announce?.();
|
|
481
|
+
announce = undefined;
|
|
426
482
|
}
|
|
427
483
|
</script>
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
// Slot-based auth forms
|
|
2
2
|
export { default as SidLoginForm } from "./SidLoginForm.vue";
|
|
3
3
|
export { default as SidRegistrationForm } from "./SidRegistrationForm.vue";
|
|
4
|
-
export type {
|
|
5
|
-
RegistrationExtras,
|
|
6
|
-
RegistrationProgress,
|
|
7
|
-
} from "./SidRegistrationForm.vue";
|
|
4
|
+
export type { RegistrationExtras } from "./SidRegistrationForm.vue";
|
|
8
5
|
export { default as SidMfaChallenge } from "./SidMfaChallenge.vue";
|
|
9
6
|
export type { MfaMethod } from "./SidMfaChallenge.vue";
|
|
10
7
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
CONFIRMED_CHANGE_STEPS,
|
|
4
|
+
DEFAULT_STEP_MS,
|
|
5
|
+
INSTALL_STEPS,
|
|
6
|
+
OperationPlan,
|
|
7
|
+
type PasswordOperationProgress,
|
|
8
|
+
type PasswordOperationStep,
|
|
9
|
+
type PlanEnvironment,
|
|
10
|
+
} from "./operationPlan";
|
|
11
|
+
|
|
12
|
+
/** A clock moved by hand and a store kept in a variable. */
|
|
13
|
+
function environment(stored: string | null = null) {
|
|
14
|
+
const env = {
|
|
15
|
+
t: 0,
|
|
16
|
+
stored,
|
|
17
|
+
now: () => env.t,
|
|
18
|
+
load: () => env.stored,
|
|
19
|
+
save: (value: string) => {
|
|
20
|
+
env.stored = value;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
return env satisfies PlanEnvironment;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function planWith(
|
|
27
|
+
stored: string | null = null,
|
|
28
|
+
steps: readonly PasswordOperationStep[] = INSTALL_STEPS,
|
|
29
|
+
) {
|
|
30
|
+
const env = environment(stored);
|
|
31
|
+
const seen: PasswordOperationProgress[] = [];
|
|
32
|
+
const plan = new OperationPlan((p) => seen.push(p), steps, env);
|
|
33
|
+
return { env, seen, plan };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const total = INSTALL_STEPS.reduce((a, s) => a + DEFAULT_STEP_MS[s], 0);
|
|
37
|
+
|
|
38
|
+
describe("OperationPlan", () => {
|
|
39
|
+
// Each step takes the gauge share its expected time takes of the whole,
|
|
40
|
+
// and a report says where the step ends and how long it should take.
|
|
41
|
+
it("gives each step its share of the expected time", () => {
|
|
42
|
+
const { seen, plan } = planWith();
|
|
43
|
+
plan.enter("protect");
|
|
44
|
+
plan.enter("compare");
|
|
45
|
+
const protect = DEFAULT_STEP_MS.protect / total;
|
|
46
|
+
expect(seen[0]).toMatchObject({
|
|
47
|
+
step: "protect",
|
|
48
|
+
fraction: 0,
|
|
49
|
+
remainingMs: DEFAULT_STEP_MS.protect,
|
|
50
|
+
});
|
|
51
|
+
expect(seen[0].until).toBeCloseTo(protect, 10);
|
|
52
|
+
expect(seen[1].fraction).toBeCloseTo(protect, 10);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// The prover's progress fills the proof's share, and what is left of the
|
|
56
|
+
// step's time shrinks with it.
|
|
57
|
+
it("fills a step with its own progress", () => {
|
|
58
|
+
const { seen, plan } = planWith();
|
|
59
|
+
plan.enter("prove");
|
|
60
|
+
plan.within(0.5);
|
|
61
|
+
const start = (DEFAULT_STEP_MS.protect + DEFAULT_STEP_MS.compare) / total;
|
|
62
|
+
const width = DEFAULT_STEP_MS.prove / total;
|
|
63
|
+
expect(seen[1].fraction).toBeCloseTo(start + width / 2, 10);
|
|
64
|
+
expect(seen[1].remainingMs).toBe(DEFAULT_STEP_MS.prove / 2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("never moves backwards", () => {
|
|
68
|
+
const { seen, plan } = planWith();
|
|
69
|
+
plan.enter("prove");
|
|
70
|
+
plan.within(0.6);
|
|
71
|
+
plan.within(0.2);
|
|
72
|
+
expect(seen[2].fraction).toBe(seen[1].fraction);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// A finished operation keeps what its steps took, as a moving average, and
|
|
76
|
+
// the next plan on this device starts from it.
|
|
77
|
+
it("learns the device's step times", () => {
|
|
78
|
+
const { env, plan } = planWith();
|
|
79
|
+
plan.enter("protect");
|
|
80
|
+
env.t = 600;
|
|
81
|
+
plan.enter("compare");
|
|
82
|
+
env.t = 1100;
|
|
83
|
+
plan.enter("prove");
|
|
84
|
+
env.t = 12_100; // a slow phone: 11 s of proving
|
|
85
|
+
plan.enter("verify");
|
|
86
|
+
env.t = 14_100;
|
|
87
|
+
plan.finish();
|
|
88
|
+
const learned = JSON.parse(env.stored ?? "{}");
|
|
89
|
+
expect(learned.prove).toBeCloseTo(
|
|
90
|
+
DEFAULT_STEP_MS.prove * 0.6 + 11_000 * 0.4,
|
|
91
|
+
6,
|
|
92
|
+
);
|
|
93
|
+
expect(learned.verify).toBeCloseTo(
|
|
94
|
+
DEFAULT_STEP_MS.verify * 0.6 + 2000 * 0.4,
|
|
95
|
+
6,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const next = planWith(env.stored);
|
|
99
|
+
next.plan.enter("prove");
|
|
100
|
+
expect(next.seen[0].remainingMs).toBeCloseTo(learned.prove, 6);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// A finished operation reports itself complete, so every progress
|
|
104
|
+
// consumer ends at the full gauge rather than where verification began.
|
|
105
|
+
it("reports completion when it finishes", () => {
|
|
106
|
+
const { seen, plan } = planWith();
|
|
107
|
+
plan.enter("verify");
|
|
108
|
+
plan.finish();
|
|
109
|
+
expect(seen.at(-1)).toMatchObject({
|
|
110
|
+
step: "verify",
|
|
111
|
+
fraction: 1,
|
|
112
|
+
until: 1,
|
|
113
|
+
remainingMs: 0,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// A change that confirms the current password gives that step its share
|
|
118
|
+
// after the new password's protection (whose request the confirmation is
|
|
119
|
+
// bound to); an operation without it never reports it and keeps its gauge.
|
|
120
|
+
it("adds the confirmation only to the plan that has it", () => {
|
|
121
|
+
const withConfirm = planWith(null, CONFIRMED_CHANGE_STEPS);
|
|
122
|
+
withConfirm.plan.enter("protect");
|
|
123
|
+
withConfirm.plan.enter("confirm");
|
|
124
|
+
const all = total + DEFAULT_STEP_MS.confirm;
|
|
125
|
+
expect(withConfirm.seen[0]).toMatchObject({ step: "protect", fraction: 0 });
|
|
126
|
+
expect(withConfirm.seen[1]).toMatchObject({ step: "confirm" });
|
|
127
|
+
expect(withConfirm.seen[1].fraction).toBeCloseTo(
|
|
128
|
+
DEFAULT_STEP_MS.protect / all,
|
|
129
|
+
10,
|
|
130
|
+
);
|
|
131
|
+
expect(withConfirm.seen[1].until).toBeCloseTo(
|
|
132
|
+
(DEFAULT_STEP_MS.protect + DEFAULT_STEP_MS.confirm) / all,
|
|
133
|
+
10,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const without = planWith();
|
|
137
|
+
without.plan.enter("confirm");
|
|
138
|
+
without.plan.enter("protect");
|
|
139
|
+
expect(without.seen).toHaveLength(1);
|
|
140
|
+
expect(without.seen[0]).toMatchObject({ step: "protect", fraction: 0 });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// A refused operation teaches nothing; an unreadable record is ignored.
|
|
144
|
+
it("ignores a malformed record and learns only from a finished operation", () => {
|
|
145
|
+
const { env, seen, plan } = planWith("{not json");
|
|
146
|
+
plan.enter("protect");
|
|
147
|
+
expect(seen[0].remainingMs).toBe(DEFAULT_STEP_MS.protect);
|
|
148
|
+
env.t = 5000;
|
|
149
|
+
plan.enter("compare");
|
|
150
|
+
expect(env.stored).toBe("{not json");
|
|
151
|
+
const bad = planWith(JSON.stringify({ prove: -1, verify: "x" }));
|
|
152
|
+
bad.plan.enter("prove");
|
|
153
|
+
expect(bad.seen[0].remainingMs).toBe(DEFAULT_STEP_MS.prove);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The time plan of a password operation, so a display shows how much is
|
|
3
|
+
* left: each step takes the share of the gauge its expected duration takes
|
|
4
|
+
* of the whole, the expected durations are what the steps took before on
|
|
5
|
+
* this device (a moving average kept in the browser), and each report says
|
|
6
|
+
* where the current step ends and how long it is expected to last from now.
|
|
7
|
+
* A display then moves at the expected pace between reports instead of
|
|
8
|
+
* jumping at each one.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What a password operation is doing, as the user can follow it: confirming
|
|
13
|
+
* the current password (a change that proves it), deriving its request on
|
|
14
|
+
* this device, comparing with previous passwords, proving, and finishing
|
|
15
|
+
* (the client's record and the server's check).
|
|
16
|
+
*/
|
|
17
|
+
export type PasswordOperationStep =
|
|
18
|
+
"confirm" | "protect" | "compare" | "prove" | "verify";
|
|
19
|
+
|
|
20
|
+
/** Progress of a password operation, for a gauge that tracks time left. */
|
|
21
|
+
export interface PasswordOperationProgress {
|
|
22
|
+
/** The step the operation is in. */
|
|
23
|
+
step?: PasswordOperationStep;
|
|
24
|
+
/** Where the operation is now, 0..1, never moving backwards. */
|
|
25
|
+
fraction: number;
|
|
26
|
+
label: string;
|
|
27
|
+
/** Where the current step ends on the gauge, 0..1. */
|
|
28
|
+
until?: number;
|
|
29
|
+
/** How long the rest of the current step is expected to take, ms. */
|
|
30
|
+
remainingMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const STEP_LABEL: Record<PasswordOperationStep, string> = {
|
|
34
|
+
confirm: "Confirming your current password",
|
|
35
|
+
protect: "Protecting your password on this device",
|
|
36
|
+
compare: "Checking it against your previous passwords",
|
|
37
|
+
prove: "Building the proof",
|
|
38
|
+
verify: "Sealing it and checking with the server",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** The steps of installing a password: registration, reset, a change. */
|
|
42
|
+
export const INSTALL_STEPS: readonly PasswordOperationStep[] = [
|
|
43
|
+
"protect",
|
|
44
|
+
"compare",
|
|
45
|
+
"prove",
|
|
46
|
+
"verify",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A change that proves the current password: the new password's start
|
|
51
|
+
* comes first, since the confirmation is bound to its request.
|
|
52
|
+
*/
|
|
53
|
+
export const CONFIRMED_CHANGE_STEPS: readonly PasswordOperationStep[] = [
|
|
54
|
+
"protect",
|
|
55
|
+
"confirm",
|
|
56
|
+
"compare",
|
|
57
|
+
"prove",
|
|
58
|
+
"verify",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const ALL_STEPS = CONFIRMED_CHANGE_STEPS;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Step times (ms) before anything was measured here: a mid-range phone
|
|
65
|
+
* (measured on a 2025 one) with the prover's key already prepared.
|
|
66
|
+
*/
|
|
67
|
+
export const DEFAULT_STEP_MS: Record<PasswordOperationStep, number> = {
|
|
68
|
+
confirm: 400,
|
|
69
|
+
protect: 150,
|
|
70
|
+
compare: 150,
|
|
71
|
+
prove: 4000,
|
|
72
|
+
verify: 2400,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Where the measured times live in the browser. */
|
|
76
|
+
const STORE_KEY = "sid:password-operation-ms";
|
|
77
|
+
/** Weight of the newest measurement in the moving average. */
|
|
78
|
+
const ALPHA = 0.4;
|
|
79
|
+
|
|
80
|
+
/** Clock and storage, replaceable in tests. */
|
|
81
|
+
export interface PlanEnvironment {
|
|
82
|
+
now: () => number;
|
|
83
|
+
load: () => string | null;
|
|
84
|
+
save: (value: string) => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const browser: PlanEnvironment = {
|
|
88
|
+
now: () => performance.now(),
|
|
89
|
+
// Storage can be missing or refuse (private mode): the defaults then apply
|
|
90
|
+
// and nothing is kept, which only costs accuracy.
|
|
91
|
+
load: () => {
|
|
92
|
+
try {
|
|
93
|
+
return globalThis.localStorage?.getItem(STORE_KEY) ?? null;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
save: (value) => {
|
|
99
|
+
try {
|
|
100
|
+
globalThis.localStorage?.setItem(STORE_KEY, value);
|
|
101
|
+
} catch {
|
|
102
|
+
// Not kept; the next operation starts from the defaults again.
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** The expected step times: measured ones where valid, the defaults otherwise. */
|
|
108
|
+
function expected(env: PlanEnvironment): Record<PasswordOperationStep, number> {
|
|
109
|
+
const out = { ...DEFAULT_STEP_MS };
|
|
110
|
+
try {
|
|
111
|
+
const stored = JSON.parse(env.load() ?? "{}") as Record<string, unknown>;
|
|
112
|
+
for (const step of ALL_STEPS) {
|
|
113
|
+
const ms = stored[step];
|
|
114
|
+
if (typeof ms === "number" && Number.isFinite(ms) && ms > 0)
|
|
115
|
+
out[step] = ms;
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// A malformed record is ignored: defaults apply.
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* One operation's plan over its `steps`: its reports, and what it measured
|
|
125
|
+
* when it ends.
|
|
126
|
+
*/
|
|
127
|
+
export class OperationPlan {
|
|
128
|
+
private readonly ms: Record<PasswordOperationStep, number>;
|
|
129
|
+
private readonly start: Partial<Record<PasswordOperationStep, number>> = {};
|
|
130
|
+
private readonly width: Partial<Record<PasswordOperationStep, number>> = {};
|
|
131
|
+
private readonly measured: Partial<Record<PasswordOperationStep, number>> =
|
|
132
|
+
{};
|
|
133
|
+
private current: PasswordOperationStep | undefined;
|
|
134
|
+
private enteredAt = 0;
|
|
135
|
+
private last = 0;
|
|
136
|
+
|
|
137
|
+
constructor(
|
|
138
|
+
private readonly report?: (p: PasswordOperationProgress) => void,
|
|
139
|
+
steps: readonly PasswordOperationStep[] = INSTALL_STEPS,
|
|
140
|
+
private readonly env: PlanEnvironment = browser,
|
|
141
|
+
) {
|
|
142
|
+
this.ms = expected(env);
|
|
143
|
+
const total = steps.reduce((sum, s) => sum + this.ms[s], 0);
|
|
144
|
+
let at = 0;
|
|
145
|
+
for (const step of steps) {
|
|
146
|
+
this.start[step] = at;
|
|
147
|
+
this.width[step] = this.ms[step] / total;
|
|
148
|
+
at += this.width[step];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The operation entered `step`; entering the step it is in changes nothing. */
|
|
153
|
+
enter(step: PasswordOperationStep): void {
|
|
154
|
+
if (this.current === step) return;
|
|
155
|
+
const now = this.env.now();
|
|
156
|
+
this.close(now);
|
|
157
|
+
this.current = step;
|
|
158
|
+
this.enteredAt = now;
|
|
159
|
+
this.emit(step, 0);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The current step reached `within` (0..1) of its own work. */
|
|
163
|
+
within(within: number): void {
|
|
164
|
+
if (this.current) this.emit(this.current, within);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The operation succeeded: keep what its steps took for the next plan, and
|
|
169
|
+
* report it complete. The server has already committed it, so an observer
|
|
170
|
+
* that throws on this report is logged and cannot fail the operation.
|
|
171
|
+
*/
|
|
172
|
+
finish(): void {
|
|
173
|
+
this.close(this.env.now());
|
|
174
|
+
const next = { ...this.ms };
|
|
175
|
+
for (const step of ALL_STEPS) {
|
|
176
|
+
const took = this.measured[step];
|
|
177
|
+
if (took !== undefined)
|
|
178
|
+
next[step] = next[step] * (1 - ALPHA) + took * ALPHA;
|
|
179
|
+
}
|
|
180
|
+
this.env.save(JSON.stringify(next));
|
|
181
|
+
if (!this.current) return;
|
|
182
|
+
// Exactly 1: the summed step shares can fall short of it by rounding.
|
|
183
|
+
this.last = 1;
|
|
184
|
+
try {
|
|
185
|
+
this.report?.({
|
|
186
|
+
step: this.current,
|
|
187
|
+
fraction: 1,
|
|
188
|
+
label: STEP_LABEL[this.current],
|
|
189
|
+
until: 1,
|
|
190
|
+
remainingMs: 0,
|
|
191
|
+
});
|
|
192
|
+
} catch (e) {
|
|
193
|
+
console.error("A progress observer failed on a committed operation", e);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private close(now: number): void {
|
|
198
|
+
if (this.current) this.measured[this.current] = now - this.enteredAt;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private emit(step: PasswordOperationStep, within: number): void {
|
|
202
|
+
const start = this.start[step];
|
|
203
|
+
const width = this.width[step];
|
|
204
|
+
// A step outside this plan is a caller's mistake: it reports nothing
|
|
205
|
+
// rather than a position the gauge does not have.
|
|
206
|
+
if (start === undefined || width === undefined) return;
|
|
207
|
+
const done = Math.min(1, Math.max(0, within));
|
|
208
|
+
const fraction = Math.max(this.last, start + width * done);
|
|
209
|
+
this.last = fraction;
|
|
210
|
+
this.report?.({
|
|
211
|
+
step,
|
|
212
|
+
fraction,
|
|
213
|
+
label: STEP_LABEL[step],
|
|
214
|
+
until: start + width,
|
|
215
|
+
remainingMs: this.ms[step] * (1 - done),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* session's Bearer token.
|
|
6
6
|
* Covers: consents, activity, linked accounts, app launcher, passwordless.
|
|
7
7
|
*/
|
|
8
|
+
import { getAccountTransport } from "../../index";
|
|
9
|
+
import { AccountServiceClient } from "@structured-id/proto/sid/v1/account/account.client";
|
|
8
10
|
import {
|
|
9
|
-
getAccountTransport,
|
|
10
|
-
AccountServiceClient,
|
|
11
11
|
type ConsentInfo,
|
|
12
12
|
type ClaimConsent,
|
|
13
13
|
type GetConsentDetailResponse,
|
|
@@ -27,8 +27,8 @@ import {
|
|
|
27
27
|
ActivityEventStatus,
|
|
28
28
|
LinkedAccountTrustCategory,
|
|
29
29
|
LinkedAccountStatus,
|
|
30
|
-
|
|
31
|
-
} from "
|
|
30
|
+
} from "@structured-id/proto/sid/v1/account/account";
|
|
31
|
+
import { Timestamp } from "@structured-id/proto/google/protobuf/timestamp";
|
|
32
32
|
import { authMeta } from "../auth";
|
|
33
33
|
|
|
34
34
|
function client(): AccountServiceClient {
|