@structured-id/ui-core 2.0.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
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { effectScope } from "vue";
|
|
3
|
+
import { RpcError } from "@protobuf-ts/runtime-rpc";
|
|
4
|
+
import { base64encode } from "@protobuf-ts/runtime";
|
|
5
|
+
import { Any } from "@structured-id/proto/google/protobuf/any";
|
|
6
|
+
import { Status } from "@structured-id/proto/google/rpc/status";
|
|
7
|
+
import { ErrorInfo } from "@structured-id/proto/google/rpc/error_details";
|
|
8
|
+
import {
|
|
9
|
+
isCurrentPasswordRequired,
|
|
10
|
+
isWrongCurrentPassword,
|
|
11
|
+
useCurrentPasswordRequirement,
|
|
12
|
+
} from "./useCurrentPasswordRequirement";
|
|
13
|
+
|
|
14
|
+
/** A refusal with `reason` and `metadata` in its ErrorInfo. */
|
|
15
|
+
function refused(reason: string, metadata: Record<string, string>): RpcError {
|
|
16
|
+
const info = Any.pack(
|
|
17
|
+
ErrorInfo.create({ reason, domain: "structured.id", metadata }),
|
|
18
|
+
ErrorInfo,
|
|
19
|
+
);
|
|
20
|
+
const status = Status.create({ code: 9, message: "", details: [info] });
|
|
21
|
+
return new RpcError("", "FAILED_PRECONDITION", {
|
|
22
|
+
"grpc-status-details-bin": base64encode(Status.toBinary(status)),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The composable inside a scope the test can end, with the server's answer given. */
|
|
27
|
+
function requirement(ms: number) {
|
|
28
|
+
const scope = effectScope();
|
|
29
|
+
const r = scope.run(() => useCurrentPasswordRequirement(async () => ms))!;
|
|
30
|
+
return { ...r, scope };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("useCurrentPasswordRequirement", () => {
|
|
34
|
+
beforeEach(() => vi.useFakeTimers());
|
|
35
|
+
afterEach(() => vi.useRealTimers());
|
|
36
|
+
|
|
37
|
+
// Until the server answers, the current password is asked for: a change
|
|
38
|
+
// that proves it is never refused.
|
|
39
|
+
it("requires the current password until the server answers", () => {
|
|
40
|
+
const r = requirement(180_000);
|
|
41
|
+
expect(r.required.value).toBe(true);
|
|
42
|
+
expect(r.known.value).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// The field appears exactly when the server's countdown ends, with no
|
|
46
|
+
// further request.
|
|
47
|
+
it("turns on when the server's countdown ends", async () => {
|
|
48
|
+
const r = requirement(180_000);
|
|
49
|
+
await r.refresh();
|
|
50
|
+
expect(r.required.value).toBe(false);
|
|
51
|
+
expect(r.known.value).toBe(true);
|
|
52
|
+
vi.advanceTimersByTime(179_999);
|
|
53
|
+
expect(r.required.value).toBe(false);
|
|
54
|
+
vi.advanceTimersByTime(1);
|
|
55
|
+
expect(r.required.value).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// The server counted from when it answered; the answer took time to
|
|
59
|
+
// arrive, so the timer is shortened by the request's own time and never
|
|
60
|
+
// fires after the server already requires the password.
|
|
61
|
+
it("subtracts the request's time from the countdown", async () => {
|
|
62
|
+
const scope = effectScope();
|
|
63
|
+
const r = scope.run(() =>
|
|
64
|
+
useCurrentPasswordRequirement(
|
|
65
|
+
() =>
|
|
66
|
+
new Promise<number>((resolve) =>
|
|
67
|
+
setTimeout(() => resolve(10_000), 400),
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
)!;
|
|
71
|
+
const refreshed = r.refresh();
|
|
72
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
73
|
+
await refreshed;
|
|
74
|
+
expect(r.required.value).toBe(false);
|
|
75
|
+
await vi.advanceTimersByTimeAsync(9_599);
|
|
76
|
+
expect(r.required.value).toBe(false);
|
|
77
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
78
|
+
expect(r.required.value).toBe(true);
|
|
79
|
+
scope.stop();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// The request's time is measured on a monotonic clock: a wall clock set
|
|
83
|
+
// forward while the answer was on its way (NTP, a user) does not shorten
|
|
84
|
+
// the countdown.
|
|
85
|
+
it("ignores wall clock changes while the answer is on its way", async () => {
|
|
86
|
+
let answer!: (ms: number) => void;
|
|
87
|
+
const scope = effectScope();
|
|
88
|
+
const r = scope.run(() =>
|
|
89
|
+
useCurrentPasswordRequirement(
|
|
90
|
+
() => new Promise<number>((resolve) => (answer = resolve)),
|
|
91
|
+
),
|
|
92
|
+
)!;
|
|
93
|
+
const refreshed = r.refresh();
|
|
94
|
+
vi.setSystemTime(Date.now() + 3_600_000);
|
|
95
|
+
answer(10_000);
|
|
96
|
+
await refreshed;
|
|
97
|
+
expect(r.required.value).toBe(false);
|
|
98
|
+
await vi.advanceTimersByTimeAsync(9_999);
|
|
99
|
+
expect(r.required.value).toBe(false);
|
|
100
|
+
scope.stop();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("is required at once when the server says zero", async () => {
|
|
104
|
+
const r = requirement(0);
|
|
105
|
+
await r.refresh();
|
|
106
|
+
expect(r.required.value).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// A refusal for lack of it wins over a countdown that has not ended (the
|
|
110
|
+
// clocks disagreed): the timer is dropped and the field stays.
|
|
111
|
+
it("requires it after a refusal, dropping the countdown", async () => {
|
|
112
|
+
const r = requirement(180_000);
|
|
113
|
+
await r.refresh();
|
|
114
|
+
r.requireNow();
|
|
115
|
+
expect(r.required.value).toBe(true);
|
|
116
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// A refusal that arrives while an answer is still on its way wins: the
|
|
120
|
+
// late answer was counted before the refusal and is stale.
|
|
121
|
+
it("keeps a refusal over an answer still on its way", async () => {
|
|
122
|
+
let answer!: (ms: number) => void;
|
|
123
|
+
const scope = effectScope();
|
|
124
|
+
const r = scope.run(() =>
|
|
125
|
+
useCurrentPasswordRequirement(
|
|
126
|
+
() => new Promise<number>((resolve) => (answer = resolve)),
|
|
127
|
+
),
|
|
128
|
+
)!;
|
|
129
|
+
const refreshed = r.refresh();
|
|
130
|
+
r.requireNow();
|
|
131
|
+
answer(180_000);
|
|
132
|
+
await refreshed;
|
|
133
|
+
expect(r.required.value).toBe(true);
|
|
134
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
135
|
+
scope.stop();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// An answer that arrives after the owner went away changes nothing and
|
|
139
|
+
// arms no timer that would outlive it.
|
|
140
|
+
it("drops an answer that arrives after its owner went away", async () => {
|
|
141
|
+
let answer!: (ms: number) => void;
|
|
142
|
+
const scope = effectScope();
|
|
143
|
+
const r = scope.run(() =>
|
|
144
|
+
useCurrentPasswordRequirement(
|
|
145
|
+
() => new Promise<number>((resolve) => (answer = resolve)),
|
|
146
|
+
),
|
|
147
|
+
)!;
|
|
148
|
+
const refreshed = r.refresh();
|
|
149
|
+
scope.stop();
|
|
150
|
+
answer(180_000);
|
|
151
|
+
await refreshed;
|
|
152
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
153
|
+
expect(r.known.value).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("stops its timer when its owner goes away", async () => {
|
|
157
|
+
const r = requirement(180_000);
|
|
158
|
+
await r.refresh();
|
|
159
|
+
r.scope.stop();
|
|
160
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("isCurrentPasswordRequired", () => {
|
|
165
|
+
// Only the step-up whose continuation is the current password counts; a
|
|
166
|
+
// step-up for a second factor needs another prompt.
|
|
167
|
+
it("recognizes only the current-password continuation", () => {
|
|
168
|
+
expect(
|
|
169
|
+
isCurrentPasswordRequired(
|
|
170
|
+
refused("STEP_UP_REQUIRED", { continuation: "current_password" }),
|
|
171
|
+
),
|
|
172
|
+
).toBe(true);
|
|
173
|
+
expect(
|
|
174
|
+
isCurrentPasswordRequired(
|
|
175
|
+
refused("STEP_UP_REQUIRED", { continuation: "step_up" }),
|
|
176
|
+
),
|
|
177
|
+
).toBe(false);
|
|
178
|
+
expect(
|
|
179
|
+
isCurrentPasswordRequired(
|
|
180
|
+
refused("PASSWORD_REUSED", { continuation: "current_password" }),
|
|
181
|
+
),
|
|
182
|
+
).toBe(false);
|
|
183
|
+
expect(isCurrentPasswordRequired(new Error("offline"))).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("isWrongCurrentPassword", () => {
|
|
188
|
+
// A change that proved a wrong current password is refused as a wrong
|
|
189
|
+
// sign-in; any other refusal is not that.
|
|
190
|
+
it("recognizes the refused sign-in", () => {
|
|
191
|
+
expect(isWrongCurrentPassword(refused("AUTHENTICATION_FAILED", {}))).toBe(
|
|
192
|
+
true,
|
|
193
|
+
);
|
|
194
|
+
expect(isWrongCurrentPassword(refused("PASSWORD_REUSED", {}))).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a password change of this session must prove the current password,
|
|
3
|
+
* kept current without polling: the server says how long until it must, by
|
|
4
|
+
* its own clock, and a timer of exactly that length turns it on. Comparing
|
|
5
|
+
* wall clocks would be wrong whenever the device's clock is off.
|
|
6
|
+
*/
|
|
7
|
+
import { onScopeDispose, readonly, ref } from "vue";
|
|
8
|
+
import { passwordChangeRequirementMs } from "./useAuthApi";
|
|
9
|
+
import { ErrorReason } from "@structured-id/proto/sid/v1/common/errors";
|
|
10
|
+
import { rpcRefusal } from "../../utils/rpcRefusal";
|
|
11
|
+
import { WrongCurrentPasswordError } from "./useAuth";
|
|
12
|
+
|
|
13
|
+
/** Whether `err` refused a change because it did not prove the current password. */
|
|
14
|
+
export function isCurrentPasswordRequired(err: unknown): boolean {
|
|
15
|
+
const refusal = rpcRefusal(err);
|
|
16
|
+
return (
|
|
17
|
+
refusal?.reasonCode === ErrorReason.STEP_UP_REQUIRED &&
|
|
18
|
+
refusal.metadata.continuation === "current_password"
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether `err` refused a change for a wrong current password: on this
|
|
24
|
+
* device, or by the server for a finalization that did not verify.
|
|
25
|
+
*/
|
|
26
|
+
export function isWrongCurrentPassword(err: unknown): boolean {
|
|
27
|
+
return (
|
|
28
|
+
err instanceof WrongCurrentPasswordError ||
|
|
29
|
+
rpcRefusal(err)?.reasonCode === ErrorReason.AUTHENTICATION_FAILED
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The requirement of the signed-in session. `required` starts true: until
|
|
35
|
+
* the server answers, asking for the current password is the safe side,
|
|
36
|
+
* since a change that proves it is always accepted.
|
|
37
|
+
*/
|
|
38
|
+
export function useCurrentPasswordRequirement(
|
|
39
|
+
requiredInMs: () => Promise<number> = passwordChangeRequirementMs,
|
|
40
|
+
) {
|
|
41
|
+
const required = ref(true);
|
|
42
|
+
const known = ref(false);
|
|
43
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
44
|
+
/** Bumped by every question and refusal; an older answer is dropped. */
|
|
45
|
+
let generations = 0;
|
|
46
|
+
|
|
47
|
+
function stop(): void {
|
|
48
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
49
|
+
timer = undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Ask the server and arm the timer for the moment it becomes required. */
|
|
53
|
+
async function refresh(): Promise<void> {
|
|
54
|
+
// The server counted from its answer, which took part of the request's
|
|
55
|
+
// time to arrive: the whole request time is taken off, so the timer
|
|
56
|
+
// fires early rather than late. Measured on the monotonic clock, which a
|
|
57
|
+
// wall clock change does not move.
|
|
58
|
+
const asked = performance.now();
|
|
59
|
+
const generation = ++generations;
|
|
60
|
+
const ms = (await requiredInMs()) - (performance.now() - asked);
|
|
61
|
+
// A refusal, or a newer answer, came while this one was on its way.
|
|
62
|
+
if (generation !== generations) return;
|
|
63
|
+
stop();
|
|
64
|
+
known.value = true;
|
|
65
|
+
required.value = ms <= 0;
|
|
66
|
+
if (ms > 0) {
|
|
67
|
+
timer = setTimeout(() => {
|
|
68
|
+
timer = undefined;
|
|
69
|
+
required.value = true;
|
|
70
|
+
}, ms);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The server refused a change for lack of it: required from now on. */
|
|
75
|
+
function requireNow(): void {
|
|
76
|
+
generations++;
|
|
77
|
+
stop();
|
|
78
|
+
known.value = true;
|
|
79
|
+
required.value = true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// An answer still on its way when the owner goes away is dropped like a
|
|
83
|
+
// stale one, so it arms no timer that would outlive the owner.
|
|
84
|
+
onScopeDispose(() => {
|
|
85
|
+
generations++;
|
|
86
|
+
stop();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
required: readonly(required),
|
|
91
|
+
known: readonly(known),
|
|
92
|
+
refresh,
|
|
93
|
+
requireNow,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -5,9 +5,13 @@ const mockGetInstanceStatus = vi.fn();
|
|
|
5
5
|
|
|
6
6
|
vi.mock("../../index", () => ({
|
|
7
7
|
getTransport: vi.fn(),
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("@structured-id/proto/sid/v1/admin/enrollment.client", () => ({
|
|
11
|
+
// Called with `new`: a constructor mock must be a `function`, not an arrow.
|
|
12
|
+
EnrollmentServiceClient: vi.fn().mockImplementation(function () {
|
|
13
|
+
return { getInstanceStatus: mockGetInstanceStatus };
|
|
14
|
+
}),
|
|
11
15
|
}));
|
|
12
16
|
|
|
13
17
|
describe("getInstanceStatus", () => {
|
|
@@ -4,11 +4,9 @@
|
|
|
4
4
|
* Wraps the public EnrollmentServiceClient calls the registration page makes
|
|
5
5
|
* before anyone signs in.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
type InstanceStatus,
|
|
11
|
-
} from "../../index";
|
|
7
|
+
import { getTransport } from "../../index";
|
|
8
|
+
import { EnrollmentServiceClient } from "@structured-id/proto/sid/v1/admin/enrollment.client";
|
|
9
|
+
import type { InstanceStatus } from "@structured-id/proto/sid/v1/admin/enrollment";
|
|
12
10
|
|
|
13
11
|
function client(): EnrollmentServiceClient {
|
|
14
12
|
return new EnrollmentServiceClient(getTransport());
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* frontend.
|
|
7
7
|
*/
|
|
8
8
|
import type { RpcTransport } from "@protobuf-ts/runtime-rpc";
|
|
9
|
+
import { getAccountTransport } from "../../index";
|
|
10
|
+
import { IdentityServiceClient } from "@structured-id/proto/sid/v1/identity/identity.client";
|
|
9
11
|
import {
|
|
10
|
-
getAccountTransport,
|
|
11
|
-
IdentityServiceClient,
|
|
12
12
|
type Profile,
|
|
13
13
|
type Session,
|
|
14
14
|
type Credential,
|
|
@@ -17,8 +17,8 @@ import {
|
|
|
17
17
|
PrincipalType,
|
|
18
18
|
CredentialType,
|
|
19
19
|
AuthenticatorAttachment,
|
|
20
|
-
|
|
21
|
-
} from "
|
|
20
|
+
} from "@structured-id/proto/sid/v1/identity/identity";
|
|
21
|
+
import { Timestamp } from "@structured-id/proto/google/protobuf/timestamp";
|
|
22
22
|
import { authMeta } from "../auth";
|
|
23
23
|
|
|
24
24
|
function client(transport?: RpcTransport): IdentityServiceClient {
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { mount } from "@vue/test-utils";
|
|
3
|
+
import SidPasswordCeremony from "./SidPasswordCeremony.vue";
|
|
4
|
+
import type { PasswordOperationProgress } from "../profile/composables/useAuth";
|
|
5
|
+
|
|
6
|
+
const stubs = {
|
|
7
|
+
"q-icon": {
|
|
8
|
+
props: ["name"],
|
|
9
|
+
template: '<i class="icon" :data-name="name" />',
|
|
10
|
+
},
|
|
11
|
+
"q-banner": { template: '<div class="reason"><slot /></div>' },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const at = (
|
|
15
|
+
step: PasswordOperationProgress["step"],
|
|
16
|
+
fraction: number,
|
|
17
|
+
label: string,
|
|
18
|
+
): PasswordOperationProgress => ({ step, fraction, label });
|
|
19
|
+
|
|
20
|
+
function mountCeremony(props: Record<string, unknown> = {}) {
|
|
21
|
+
return mount(SidPasswordCeremony, {
|
|
22
|
+
props: { progress: null, outcome: "running", length: 12, ...props },
|
|
23
|
+
global: { stubs },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type Ceremony = ReturnType<typeof mountCeremony>;
|
|
28
|
+
const rows = (w: Ceremony) => w.findAll(".sid-ceremony__row");
|
|
29
|
+
const glyphs = (w: Ceremony) =>
|
|
30
|
+
rows(w)[0]
|
|
31
|
+
.findAll(".sid-ceremony__glyph")
|
|
32
|
+
.map((g) => g.text());
|
|
33
|
+
const label = (w: Ceremony) => w.find(".sid-ceremony__label").text();
|
|
34
|
+
const phase = (w: Ceremony) =>
|
|
35
|
+
w
|
|
36
|
+
.classes()
|
|
37
|
+
.find((c) => c.startsWith("sid-ceremony--") && c !== "sid-ceremony--still");
|
|
38
|
+
|
|
39
|
+
/** Past the opening: the rows have turned into digits and merged. */
|
|
40
|
+
async function merged() {
|
|
41
|
+
await vi.advanceTimersByTimeAsync(1300);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("SidPasswordCeremony", () => {
|
|
45
|
+
beforeEach(() => vi.useFakeTimers());
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.useRealTimers();
|
|
48
|
+
vi.unstubAllGlobals();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// One row per password field, each as long as the password (capped);
|
|
52
|
+
// the rows are digits, never the password's characters.
|
|
53
|
+
it("turns each field into a row of digits", async () => {
|
|
54
|
+
const w = mountCeremony({ fields: 2, length: 15 });
|
|
55
|
+
await vi.advanceTimersByTimeAsync(320);
|
|
56
|
+
expect(rows(w)).toHaveLength(2);
|
|
57
|
+
expect(glyphs(w)).toHaveLength(15);
|
|
58
|
+
expect(glyphs(w).some((g) => /^[0-9]$/.test(g))).toBe(true);
|
|
59
|
+
expect(glyphs(w).every((g) => /^[0-9●]$/.test(g))).toBe(true);
|
|
60
|
+
expect(glyphs(mountCeremony({ length: 80 }))).toHaveLength(20);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// The rows start in the fields' places and merge into one centre line.
|
|
64
|
+
it("merges the rows into one line", async () => {
|
|
65
|
+
const w = mountCeremony({ fields: 2 });
|
|
66
|
+
expect(phase(w)).toBe("sid-ceremony--split");
|
|
67
|
+
expect(rows(w)[1].attributes("style")).toContain("translateY(72px)");
|
|
68
|
+
await merged();
|
|
69
|
+
expect(phase(w)).toBe("sid-ceremony--line");
|
|
70
|
+
expect(rows(w)[0].attributes("style")).toContain("translateY(36px)");
|
|
71
|
+
expect(rows(w)[1].attributes("style")).toContain("translateY(36px)");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// The digits settle into dots from the left as far as the operation went.
|
|
75
|
+
it("settles digits into dots as the operation advances", async () => {
|
|
76
|
+
const w = mountCeremony({ length: 10 });
|
|
77
|
+
await merged();
|
|
78
|
+
await w.setProps({ progress: at("prove", 0.5, "Building the proof") });
|
|
79
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
80
|
+
expect(glyphs(w).slice(0, 5)).toEqual(["●", "●", "●", "●", "●"]);
|
|
81
|
+
expect(w.find("[role=progressbar]").attributes("aria-valuenow")).toBe("50");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Between reports the line moves at the pace the report expects for the
|
|
85
|
+
// rest of its step, so it shows the time left; it stops short of the
|
|
86
|
+
// step's end until the step reports done, and never moves back.
|
|
87
|
+
it("moves at the expected pace between reports", async () => {
|
|
88
|
+
const w = mountCeremony();
|
|
89
|
+
const now = () =>
|
|
90
|
+
Number(w.find("[role=progressbar]").attributes("aria-valuenow"));
|
|
91
|
+
await w.setProps({
|
|
92
|
+
progress: {
|
|
93
|
+
step: "prove",
|
|
94
|
+
fraction: 0.2,
|
|
95
|
+
label: "Building the proof",
|
|
96
|
+
until: 0.6,
|
|
97
|
+
remainingMs: 2000,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
101
|
+
expect(now()).toBeGreaterThanOrEqual(39);
|
|
102
|
+
expect(now()).toBeLessThanOrEqual(41);
|
|
103
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
104
|
+
expect(now(), "short of the step's end").toBe(58);
|
|
105
|
+
await w.setProps({
|
|
106
|
+
progress: {
|
|
107
|
+
step: "prove",
|
|
108
|
+
fraction: 0.3,
|
|
109
|
+
label: "Building the proof",
|
|
110
|
+
until: 0.6,
|
|
111
|
+
remainingMs: 1500,
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
await vi.advanceTimersByTimeAsync(40);
|
|
115
|
+
expect(now(), "a late report never moves it back").toBe(58);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// The pace is measured on a monotonic clock: the device's wall clock moved
|
|
119
|
+
// forward by an hour mid-step does not jump the line to the step's end.
|
|
120
|
+
it("keeps its pace when the wall clock is changed", async () => {
|
|
121
|
+
const w = mountCeremony();
|
|
122
|
+
const now = () =>
|
|
123
|
+
Number(w.find("[role=progressbar]").attributes("aria-valuenow"));
|
|
124
|
+
await w.setProps({
|
|
125
|
+
progress: {
|
|
126
|
+
step: "prove",
|
|
127
|
+
fraction: 0.2,
|
|
128
|
+
label: "Building the proof",
|
|
129
|
+
until: 0.6,
|
|
130
|
+
remainingMs: 2000,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
vi.setSystemTime(Date.now() + 3_600_000);
|
|
134
|
+
await vi.advanceTimersByTimeAsync(40);
|
|
135
|
+
expect(now()).toBeLessThanOrEqual(22);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// A report present when the ceremony mounts counts as much as a later one.
|
|
139
|
+
it("starts from the progress it is mounted with", async () => {
|
|
140
|
+
const w = mountCeremony({
|
|
141
|
+
progress: at("prove", 0.5, "Building the proof"),
|
|
142
|
+
});
|
|
143
|
+
await vi.advanceTimersByTimeAsync(40);
|
|
144
|
+
expect(w.find("[role=progressbar]").attributes("aria-valuenow")).toBe("50");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// A prover still at its first stage keeps reporting the step's start; the
|
|
148
|
+
// line goes on at the expected pace instead of restarting from it.
|
|
149
|
+
it("is not held back by reports that lag behind it", async () => {
|
|
150
|
+
const w = mountCeremony();
|
|
151
|
+
const now = () =>
|
|
152
|
+
Number(w.find("[role=progressbar]").attributes("aria-valuenow"));
|
|
153
|
+
const lagging = {
|
|
154
|
+
step: "prove" as const,
|
|
155
|
+
fraction: 0.2,
|
|
156
|
+
label: "Building the proof",
|
|
157
|
+
until: 0.6,
|
|
158
|
+
remainingMs: 4000,
|
|
159
|
+
};
|
|
160
|
+
await w.setProps({ progress: lagging });
|
|
161
|
+
for (let i = 0; i < 10; i++) {
|
|
162
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
163
|
+
await w.setProps({ progress: { ...lagging } });
|
|
164
|
+
}
|
|
165
|
+
// 2 s of a 4 s step: about halfway from 0.2 to 0.6.
|
|
166
|
+
expect(now()).toBeGreaterThanOrEqual(36);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// A short step never replaces the label on screen; a lasting one does.
|
|
170
|
+
it("shows a step's label only once the step has lasted", async () => {
|
|
171
|
+
const w = mountCeremony();
|
|
172
|
+
expect(label(w)).toBe("Protecting your password on this device");
|
|
173
|
+
await w.setProps({ progress: at("protect", 0, "Protecting") });
|
|
174
|
+
expect(label(w)).toBe("Protecting");
|
|
175
|
+
await w.setProps({ progress: at("compare", 0.08, "Comparing") });
|
|
176
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
177
|
+
await w.setProps({ progress: at("prove", 0.2, "Building the proof") });
|
|
178
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
179
|
+
expect(label(w)).toBe("Protecting");
|
|
180
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
181
|
+
expect(label(w)).toBe("Building the proof");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Success: the line closes into the orb, the lock appears, then the
|
|
185
|
+
// check; only then is the form told.
|
|
186
|
+
it("closes into the orb, then the lock, then the check", async () => {
|
|
187
|
+
const w = mountCeremony({ acceptedText: "Password changed" });
|
|
188
|
+
await merged();
|
|
189
|
+
await w.setProps({ outcome: "accepted" });
|
|
190
|
+
expect(phase(w)).toBe("sid-ceremony--orb");
|
|
191
|
+
expect(
|
|
192
|
+
w.find("[role=progressbar]").attributes("aria-valuenow"),
|
|
193
|
+
"an accepted operation is complete",
|
|
194
|
+
).toBe("100");
|
|
195
|
+
expect(w.find(".sid-ceremony__orb").exists()).toBe(true);
|
|
196
|
+
expect(w.find(".icon").exists()).toBe(false);
|
|
197
|
+
await vi.advanceTimersByTimeAsync(650);
|
|
198
|
+
expect(w.find(".icon").attributes("data-name")).toBe("sym_o_lock");
|
|
199
|
+
expect(label(w)).toBe("Password changed");
|
|
200
|
+
await vi.advanceTimersByTimeAsync(450);
|
|
201
|
+
expect(w.find(".icon").attributes("data-name")).toBe("sym_o_check");
|
|
202
|
+
expect(w.emitted("settled")).toBeUndefined();
|
|
203
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
204
|
+
expect(w.emitted("settled")).toEqual([["accepted"]]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Refusal: no orb ever forms; the line unfolds back into the fields as
|
|
208
|
+
// dots, the reason shows above them, then the form takes over.
|
|
209
|
+
it("unfolds back into the fields after a refusal", async () => {
|
|
210
|
+
const w = mountCeremony({ fields: 2, refusedText: "Used before" });
|
|
211
|
+
await merged();
|
|
212
|
+
await w.setProps({ outcome: "refused" });
|
|
213
|
+
expect(w.find(".sid-ceremony__orb").exists()).toBe(false);
|
|
214
|
+
expect(phase(w)).toBe("sid-ceremony--unfolding");
|
|
215
|
+
expect(rows(w)[1].attributes("style")).toContain("translateY(72px)");
|
|
216
|
+
expect(glyphs(w).every((g) => g === "●")).toBe(true);
|
|
217
|
+
await vi.advanceTimersByTimeAsync(650);
|
|
218
|
+
expect(w.find(".reason").text()).toBe("Used before");
|
|
219
|
+
expect(w.emitted("settled")).toBeUndefined();
|
|
220
|
+
await vi.advanceTimersByTimeAsync(1600);
|
|
221
|
+
expect(w.emitted("settled")).toEqual([["refused"]]);
|
|
222
|
+
expect(w.find(".sid-ceremony__orb").exists()).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// An outcome that arrives while the rows are still merging waits for the
|
|
226
|
+
// line, so the choreography is never cut short, and runs once.
|
|
227
|
+
it("lets the rows merge before an early outcome", async () => {
|
|
228
|
+
const w = mountCeremony();
|
|
229
|
+
await w.setProps({ outcome: "refused" });
|
|
230
|
+
expect(phase(w)).toBe("sid-ceremony--split");
|
|
231
|
+
await merged();
|
|
232
|
+
expect(phase(w)).toBe("sid-ceremony--unfolding");
|
|
233
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
234
|
+
expect(w.emitted("settled")).toEqual([["refused"]]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// With reduced motion nothing churns and every step lands at once.
|
|
238
|
+
it("does not animate under reduced motion", async () => {
|
|
239
|
+
// jsdom has no matchMedia: the browser's answer is given here.
|
|
240
|
+
vi.stubGlobal(
|
|
241
|
+
"matchMedia",
|
|
242
|
+
(query: string) =>
|
|
243
|
+
({ matches: query.includes("reduce") }) as MediaQueryList,
|
|
244
|
+
);
|
|
245
|
+
const w = mountCeremony();
|
|
246
|
+
expect(w.classes()).toContain("sid-ceremony--still");
|
|
247
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
248
|
+
expect(glyphs(w).every((g) => g === "●")).toBe(true);
|
|
249
|
+
await w.setProps({ outcome: "accepted" });
|
|
250
|
+
await vi.runAllTimersAsync();
|
|
251
|
+
expect(w.emitted("settled")).toEqual([["accepted"]]);
|
|
252
|
+
});
|
|
253
|
+
});
|