@structured-id/ui-core 4.12.0 → 4.14.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/package.json +3 -2
- package/src/profile/components/SessionList.vue +9 -8
- package/src/profile/components/SidLoginForm.vue +3 -2
- package/src/profile/composables/useAuth.test.ts +37 -34
- package/src/profile/composables/useAuth.ts +9 -9
- package/src/profile/composables/useAuthApi.ids.test.ts +248 -0
- package/src/profile/composables/useAuthApi.opaque.test.ts +55 -93
- package/src/profile/composables/useAuthApi.ts +189 -92
- package/src/profile/composables/useIdentityApi.test.ts +166 -0
- package/src/profile/composables/useIdentityApi.ts +97 -42
- package/src/profile/composables/useLoginSuccess.test.ts +1 -2
- package/src/profile/composables/useLoginSuccess.ts +2 -1
- package/src/profile/composables/useSecurityLevel.test.ts +0 -2
- package/src/stores/auth.test.ts +9 -11
- package/src/stores/auth.ts +4 -7
- package/src/types/auth.ts +0 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@structured-id/ui-core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"@protobuf-ts/grpcweb-transport": "^2.11.1",
|
|
43
43
|
"@protobuf-ts/runtime": "^2.11.1",
|
|
44
44
|
"@protobuf-ts/runtime-rpc": "^2.11.1",
|
|
45
|
-
"@structured-id/
|
|
45
|
+
"@structured-id/ids": "^0.4.0",
|
|
46
|
+
"@structured-id/proto": "^0.12.0",
|
|
46
47
|
"@structured-world/structured-email-address-ts": "^0.0.22",
|
|
47
48
|
"@structured-world/structured-public-domains": "0.0.x",
|
|
48
49
|
"libphonenumber-js": "^1.13.15"
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
</div>
|
|
10
10
|
|
|
11
11
|
<q-list v-else-if="sessions.length > 0" separator>
|
|
12
|
-
<q-item v-for="session in sessions" :key="session.id">
|
|
12
|
+
<q-item v-for="session in sessions" :key="session.id.toString()">
|
|
13
13
|
<q-item-section avatar>
|
|
14
14
|
<q-icon :name="deviceIcon(session.userAgent)" size="sm" />
|
|
15
15
|
</q-item-section>
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
dense
|
|
38
38
|
color="negative"
|
|
39
39
|
icon="sym_o_logout"
|
|
40
|
-
:loading="revoking
|
|
40
|
+
:loading="revoking?.equals(session.id) ?? false"
|
|
41
41
|
@click="$emit('revoke', session.id)"
|
|
42
42
|
/>
|
|
43
43
|
</q-item-section>
|
|
@@ -49,23 +49,24 @@
|
|
|
49
49
|
</template>
|
|
50
50
|
|
|
51
51
|
<script setup lang="ts">
|
|
52
|
-
import type {
|
|
52
|
+
import type { SessionId } from "@structured-id/ids";
|
|
53
53
|
import { Timestamp } from "@structured-id/proto/google/protobuf/timestamp";
|
|
54
|
+
import type { Session } from "../composables/useIdentityApi";
|
|
54
55
|
|
|
55
|
-
defineProps<{
|
|
56
|
+
const props = defineProps<{
|
|
56
57
|
sessions: Session[];
|
|
57
58
|
loading: boolean;
|
|
58
59
|
error: string | null;
|
|
59
|
-
revoking:
|
|
60
|
-
currentSessionId?:
|
|
60
|
+
revoking: SessionId | null;
|
|
61
|
+
currentSessionId?: SessionId;
|
|
61
62
|
}>();
|
|
62
63
|
|
|
63
64
|
defineEmits<{
|
|
64
|
-
revoke: [sessionId:
|
|
65
|
+
revoke: [sessionId: SessionId];
|
|
65
66
|
}>();
|
|
66
67
|
|
|
67
68
|
function isCurrentSession(session: Session): boolean {
|
|
68
|
-
return
|
|
69
|
+
return props.currentSessionId?.equals(session.id) ?? false;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
function deviceIcon(userAgent: string | undefined): string {
|
|
@@ -120,6 +120,7 @@
|
|
|
120
120
|
|
|
121
121
|
<script setup lang="ts">
|
|
122
122
|
import { computed, ref } from "vue";
|
|
123
|
+
import type { SessionId } from "@structured-id/ids";
|
|
123
124
|
import {
|
|
124
125
|
ALL_PRINCIPAL_TYPES,
|
|
125
126
|
DEFAULT_PRINCIPAL_INPUT_TEXTS,
|
|
@@ -176,7 +177,7 @@ const props = withDefaults(
|
|
|
176
177
|
onStep?: (step: number) => void,
|
|
177
178
|
) => Promise<{
|
|
178
179
|
expiresIn: number;
|
|
179
|
-
sessionId?:
|
|
180
|
+
sessionId?: SessionId;
|
|
180
181
|
}>;
|
|
181
182
|
}>(),
|
|
182
183
|
{
|
|
@@ -206,7 +207,7 @@ const props = withDefaults(
|
|
|
206
207
|
);
|
|
207
208
|
|
|
208
209
|
const emit = defineEmits<{
|
|
209
|
-
success: [result: { expiresIn: number; sessionId?:
|
|
210
|
+
success: [result: { expiresIn: number; sessionId?: SessionId }];
|
|
210
211
|
error: [error: Error];
|
|
211
212
|
"forgot-password": [];
|
|
212
213
|
}>();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
|
|
2
|
+
import { CredentialId, OpaqueLoginId, SessionId } from "@structured-id/ids";
|
|
2
3
|
import {
|
|
3
4
|
createAuth,
|
|
4
5
|
type ZkppClientApi,
|
|
@@ -51,6 +52,9 @@ const api = {
|
|
|
51
52
|
// ── Fixtures ──
|
|
52
53
|
|
|
53
54
|
const OPERATION = new Uint8Array(16).fill(7);
|
|
55
|
+
const CRED = CredentialId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d01");
|
|
56
|
+
const LOGIN = OpaqueLoginId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d02");
|
|
57
|
+
const SESSION = SessionId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d03");
|
|
54
58
|
const OWNER_DOMAIN = new Uint8Array(32).fill(1);
|
|
55
59
|
const DOMAIN = new Uint8Array(32).fill(2);
|
|
56
60
|
const KEY = new Uint8Array(32).fill(3);
|
|
@@ -121,10 +125,10 @@ describe("createAuth prewarm", () => {
|
|
|
121
125
|
beforeEach(() => {
|
|
122
126
|
api.loginStart.mockResolvedValue({
|
|
123
127
|
credentialResponse: new Uint8Array([4]),
|
|
124
|
-
|
|
128
|
+
loginId: LOGIN,
|
|
125
129
|
});
|
|
126
130
|
api.loginFinish.mockResolvedValue({
|
|
127
|
-
sessionId:
|
|
131
|
+
sessionId: SESSION,
|
|
128
132
|
expiresIn: 60n,
|
|
129
133
|
secondFactors: [],
|
|
130
134
|
enrollableFactors: [],
|
|
@@ -178,7 +182,7 @@ describe("createAuth register", () => {
|
|
|
178
182
|
// current message no longer has.
|
|
179
183
|
const fromOlderServer = {
|
|
180
184
|
profileId: "profile-uuid",
|
|
181
|
-
credentialId:
|
|
185
|
+
credentialId: CRED,
|
|
182
186
|
};
|
|
183
187
|
api.regFinish.mockResolvedValue(fromOlderServer);
|
|
184
188
|
});
|
|
@@ -214,7 +218,7 @@ describe("createAuth register", () => {
|
|
|
214
218
|
expect(api.regFinish).toHaveBeenCalledWith(OPERATION, RECORD, WIRE_PROOF);
|
|
215
219
|
// Regression: the result passed the server's Profile identifier on to
|
|
216
220
|
// the application; even a server still sending one is not forwarded.
|
|
217
|
-
expect(result).toEqual({ credentialId:
|
|
221
|
+
expect(result).toEqual({ credentialId: CRED });
|
|
218
222
|
});
|
|
219
223
|
|
|
220
224
|
// A password the circuit cannot hold has no history request: nothing is
|
|
@@ -366,19 +370,15 @@ describe("createAuth changePassword", () => {
|
|
|
366
370
|
it("fixes the OPAQUE start in the challenge, executes, proves and finishes", async () => {
|
|
367
371
|
const { changePassword } = createAuth(loader);
|
|
368
372
|
await changePassword({
|
|
369
|
-
credentialId:
|
|
373
|
+
credentialId: CRED,
|
|
370
374
|
newPassword: "N3wP@ssword!",
|
|
371
375
|
});
|
|
372
376
|
|
|
373
377
|
expect(client.registrationStart).toHaveBeenCalledOnce();
|
|
374
378
|
expect(client.registrationStart).toHaveBeenCalledWith("N3wP@ssword!");
|
|
375
|
-
expect(api.challenge).toHaveBeenCalledWith(
|
|
379
|
+
expect(api.challenge).toHaveBeenCalledWith(CRED, START.request);
|
|
376
380
|
expect(client.loginStart).not.toHaveBeenCalled();
|
|
377
|
-
expect(api.execute).toHaveBeenCalledWith(
|
|
378
|
-
OPERATION,
|
|
379
|
-
"cred-1",
|
|
380
|
-
new Uint8Array(),
|
|
381
|
-
);
|
|
381
|
+
expect(api.execute).toHaveBeenCalledWith(OPERATION, CRED, new Uint8Array());
|
|
382
382
|
expect(api.evaluate).toHaveBeenCalledWith(OPERATION, HISTORY.blinded);
|
|
383
383
|
expect(client.prove).toHaveBeenCalledWith(
|
|
384
384
|
"N3wP@ssword!",
|
|
@@ -387,7 +387,7 @@ describe("createAuth changePassword", () => {
|
|
|
387
387
|
);
|
|
388
388
|
expect(api.changeFinish).toHaveBeenCalledWith(
|
|
389
389
|
OPERATION,
|
|
390
|
-
|
|
390
|
+
CRED,
|
|
391
391
|
RECORD,
|
|
392
392
|
WIRE_PROOF,
|
|
393
393
|
);
|
|
@@ -400,7 +400,7 @@ describe("createAuth changePassword", () => {
|
|
|
400
400
|
const { changePassword } = createAuth(loader);
|
|
401
401
|
await expect(
|
|
402
402
|
changePassword(
|
|
403
|
-
{ credentialId:
|
|
403
|
+
{ credentialId: CRED, newPassword: "N3wP@ssword!" },
|
|
404
404
|
(p) => {
|
|
405
405
|
if (p.fraction === 1) throw new Error("gauge unmounted");
|
|
406
406
|
},
|
|
@@ -420,7 +420,7 @@ describe("createAuth changePassword", () => {
|
|
|
420
420
|
});
|
|
421
421
|
const { changePassword } = createAuth(loader);
|
|
422
422
|
await changePassword(
|
|
423
|
-
{ credentialId:
|
|
423
|
+
{ credentialId: CRED, newPassword: "N3wP@ssword!" },
|
|
424
424
|
(p) => {
|
|
425
425
|
if (p.fraction === 0 || order.at(-1) !== `step:${p.step}`)
|
|
426
426
|
order.push(`step:${p.step}`);
|
|
@@ -444,7 +444,7 @@ describe("createAuth changePassword", () => {
|
|
|
444
444
|
const { changePassword } = createAuth(loader);
|
|
445
445
|
await changePassword(
|
|
446
446
|
{
|
|
447
|
-
credentialId:
|
|
447
|
+
credentialId: CRED,
|
|
448
448
|
newPassword: "N3wP@ssword!",
|
|
449
449
|
currentPassword: "0ldP@ssword!",
|
|
450
450
|
},
|
|
@@ -461,7 +461,7 @@ describe("createAuth changePassword", () => {
|
|
|
461
461
|
expect(client.registrationStart).toHaveBeenCalledOnce();
|
|
462
462
|
expect(client.loginStart).toHaveBeenCalledWith("0ldP@ssword!");
|
|
463
463
|
expect(api.challenge).toHaveBeenCalledWith(
|
|
464
|
-
|
|
464
|
+
CRED,
|
|
465
465
|
START.request,
|
|
466
466
|
new Uint8Array([1, 2, 3]),
|
|
467
467
|
);
|
|
@@ -473,7 +473,7 @@ describe("createAuth changePassword", () => {
|
|
|
473
473
|
);
|
|
474
474
|
expect(api.execute).toHaveBeenCalledWith(
|
|
475
475
|
OPERATION,
|
|
476
|
-
|
|
476
|
+
CRED,
|
|
477
477
|
new Uint8Array([7, 8, 9]),
|
|
478
478
|
);
|
|
479
479
|
expect(seen[0]).toMatchObject({ step: "protect", fraction: 0 });
|
|
@@ -499,7 +499,7 @@ describe("createAuth changePassword", () => {
|
|
|
499
499
|
const { changePassword } = createAuth(loader);
|
|
500
500
|
await changePassword(
|
|
501
501
|
{
|
|
502
|
-
credentialId:
|
|
502
|
+
credentialId: CRED,
|
|
503
503
|
newPassword: "N3wP@ssword!",
|
|
504
504
|
currentPassword: "0ldP@ssword!",
|
|
505
505
|
},
|
|
@@ -529,7 +529,7 @@ describe("createAuth changePassword", () => {
|
|
|
529
529
|
});
|
|
530
530
|
const { changePassword } = createAuth(loader);
|
|
531
531
|
await changePassword({
|
|
532
|
-
credentialId:
|
|
532
|
+
credentialId: CRED,
|
|
533
533
|
newPassword: "N3wP@ssword!",
|
|
534
534
|
});
|
|
535
535
|
expect(loader).toHaveBeenCalledOnce();
|
|
@@ -552,7 +552,7 @@ describe("createAuth changePassword", () => {
|
|
|
552
552
|
const { changePassword } = createAuth(loader);
|
|
553
553
|
await expect(
|
|
554
554
|
changePassword({
|
|
555
|
-
credentialId:
|
|
555
|
+
credentialId: CRED,
|
|
556
556
|
newPassword: "N3wP@ssword!",
|
|
557
557
|
currentPassword: "wrong",
|
|
558
558
|
}),
|
|
@@ -574,7 +574,7 @@ describe("createAuth changePassword", () => {
|
|
|
574
574
|
const { changePassword } = createAuth(loader);
|
|
575
575
|
await expect(
|
|
576
576
|
changePassword({
|
|
577
|
-
credentialId:
|
|
577
|
+
credentialId: CRED,
|
|
578
578
|
newPassword: "N3wP@ssword!",
|
|
579
579
|
currentPassword: "0ldP@ssword!",
|
|
580
580
|
}),
|
|
@@ -595,7 +595,7 @@ describe("createAuth changePassword", () => {
|
|
|
595
595
|
const { changePassword } = createAuth(loader);
|
|
596
596
|
await expect(
|
|
597
597
|
changePassword({
|
|
598
|
-
credentialId:
|
|
598
|
+
credentialId: CRED,
|
|
599
599
|
newPassword: "N3wP@ssword!",
|
|
600
600
|
currentPassword: "",
|
|
601
601
|
}),
|
|
@@ -619,7 +619,7 @@ describe("createAuth changePassword", () => {
|
|
|
619
619
|
const { changePassword } = createAuth(loader);
|
|
620
620
|
await expect(
|
|
621
621
|
changePassword({
|
|
622
|
-
credentialId:
|
|
622
|
+
credentialId: CRED,
|
|
623
623
|
newPassword: "N3wP@ssword!",
|
|
624
624
|
currentPassword: "0ldP@ssword!",
|
|
625
625
|
}),
|
|
@@ -634,7 +634,7 @@ describe("createAuth changePassword", () => {
|
|
|
634
634
|
});
|
|
635
635
|
const { changePassword } = createAuth(loader);
|
|
636
636
|
await expect(
|
|
637
|
-
changePassword({ credentialId:
|
|
637
|
+
changePassword({ credentialId: CRED, newPassword: "pw" }),
|
|
638
638
|
).rejects.toThrow(/did not prepare/);
|
|
639
639
|
expect(api.execute).not.toHaveBeenCalled();
|
|
640
640
|
expect(client.prove).not.toHaveBeenCalled();
|
|
@@ -644,7 +644,7 @@ describe("createAuth changePassword", () => {
|
|
|
644
644
|
api.changeFinish.mockRejectedValue(new Error("PASSWORD_REUSED"));
|
|
645
645
|
const { changePassword } = createAuth(loader);
|
|
646
646
|
await expect(
|
|
647
|
-
changePassword({ credentialId:
|
|
647
|
+
changePassword({ credentialId: CRED, newPassword: "pw" }),
|
|
648
648
|
).rejects.toThrow("PASSWORD_REUSED");
|
|
649
649
|
});
|
|
650
650
|
});
|
|
@@ -653,28 +653,29 @@ describe("createAuth resetPassword", () => {
|
|
|
653
653
|
beforeEach(() => {
|
|
654
654
|
api.executeReset.mockResolvedValue({ registrationResponse: RESPONSE });
|
|
655
655
|
api.completeReset.mockResolvedValue({
|
|
656
|
-
sessionId:
|
|
656
|
+
sessionId: SESSION,
|
|
657
657
|
expiresIn: 3600,
|
|
658
658
|
});
|
|
659
659
|
});
|
|
660
660
|
|
|
661
|
+
// The reset is named by its operation alone: the operation carries the
|
|
662
|
+
// verified reset's authority, so no reset session travels with it.
|
|
661
663
|
it("executes the OPAQUE start under the reset operation and completes with the proof", async () => {
|
|
662
664
|
const { resetPassword } = createAuth(loader);
|
|
663
|
-
const done = await resetPassword(
|
|
665
|
+
const done = await resetPassword(wireContext(), "N3wP@ssword!");
|
|
664
666
|
|
|
665
667
|
expect(api.executeReset).toHaveBeenCalledWith(OPERATION, START.request);
|
|
666
668
|
expect(api.completeReset).toHaveBeenCalledWith(
|
|
667
|
-
"reset-1",
|
|
668
669
|
OPERATION,
|
|
669
670
|
RECORD,
|
|
670
671
|
WIRE_PROOF,
|
|
671
672
|
);
|
|
672
|
-
expect(done.sessionId).toBe(
|
|
673
|
+
expect(done.sessionId).toBe(SESSION);
|
|
673
674
|
});
|
|
674
675
|
|
|
675
676
|
it("refuses a verification that prepared no operation", async () => {
|
|
676
677
|
const { resetPassword } = createAuth(loader);
|
|
677
|
-
await expect(resetPassword(
|
|
678
|
+
await expect(resetPassword(undefined, "pw")).rejects.toThrow(
|
|
678
679
|
/did not prepare/,
|
|
679
680
|
);
|
|
680
681
|
expect(client.registrationStart).not.toHaveBeenCalled();
|
|
@@ -685,16 +686,18 @@ describe("createAuth login", () => {
|
|
|
685
686
|
beforeEach(() => {
|
|
686
687
|
api.loginStart.mockResolvedValue({
|
|
687
688
|
credentialResponse: new Uint8Array([4, 5, 6]),
|
|
688
|
-
|
|
689
|
+
loginId: LOGIN,
|
|
689
690
|
});
|
|
690
691
|
api.loginFinish.mockResolvedValue({
|
|
691
|
-
sessionId:
|
|
692
|
+
sessionId: SESSION,
|
|
692
693
|
expiresIn: BigInt(3600),
|
|
693
694
|
secondFactors: [],
|
|
694
695
|
enrollableFactors: [],
|
|
695
696
|
});
|
|
696
697
|
});
|
|
697
698
|
|
|
699
|
+
// The finish names the login its start began, by the identifier the start
|
|
700
|
+
// answered, not by any state of the login.
|
|
698
701
|
it("signs in with the Pallas client in two round trips", async () => {
|
|
699
702
|
const { login } = createAuth(loader);
|
|
700
703
|
const steps: number[] = [];
|
|
@@ -714,13 +717,13 @@ describe("createAuth login", () => {
|
|
|
714
717
|
expect(api.loginFinish).toHaveBeenCalledWith(
|
|
715
718
|
"alice@test.com",
|
|
716
719
|
new Uint8Array([7, 8, 9]),
|
|
717
|
-
|
|
720
|
+
LOGIN,
|
|
718
721
|
);
|
|
719
722
|
expect(steps).toEqual([1, 2, 3]);
|
|
720
723
|
// The result carries no credential: the browser holds the session
|
|
721
724
|
// cookie the answer set.
|
|
722
725
|
expect(result).toEqual({
|
|
723
|
-
sessionId:
|
|
726
|
+
sessionId: SESSION,
|
|
724
727
|
expiresIn: 3600,
|
|
725
728
|
});
|
|
726
729
|
});
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* `@structured-id/opaque`), keeping kernel delivery at the app layer.
|
|
15
15
|
* gRPC calls go through useAuthApi wrappers.
|
|
16
16
|
*/
|
|
17
|
+
import type { CredentialId, SessionId } from "@structured-id/ids";
|
|
17
18
|
import {
|
|
18
19
|
completePasswordReset,
|
|
19
20
|
evaluatePasswordHistory,
|
|
@@ -25,10 +26,10 @@ import {
|
|
|
25
26
|
passwordChangeChallenge,
|
|
26
27
|
passwordChangeExecute,
|
|
27
28
|
passwordChangeFinish,
|
|
28
|
-
type CompletePasswordResetResponse,
|
|
29
29
|
type PasswordHistoryContext,
|
|
30
30
|
type PasswordHistoryEvaluation,
|
|
31
31
|
type PasswordRegistrationProof,
|
|
32
|
+
type PasswordResetResult,
|
|
32
33
|
} from "./useAuthApi";
|
|
33
34
|
|
|
34
35
|
// ── Types ──
|
|
@@ -38,7 +39,7 @@ import {
|
|
|
38
39
|
* reaches page code: a browser holds the session cookie the answer set.
|
|
39
40
|
*/
|
|
40
41
|
export interface LoginResult {
|
|
41
|
-
sessionId:
|
|
42
|
+
sessionId: SessionId;
|
|
42
43
|
/** Seconds until the delivered credential expires. */
|
|
43
44
|
expiresIn: number;
|
|
44
45
|
}
|
|
@@ -48,7 +49,7 @@ export interface LoginResult {
|
|
|
48
49
|
* signs in after registering and works through its session.
|
|
49
50
|
*/
|
|
50
51
|
export interface RegisterResult {
|
|
51
|
-
credentialId:
|
|
52
|
+
credentialId: CredentialId;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
import {
|
|
@@ -69,7 +70,7 @@ export class WrongCurrentPasswordError extends Error {
|
|
|
69
70
|
/** A password change of the signed-in user. */
|
|
70
71
|
export interface PasswordChange {
|
|
71
72
|
/** The password credential to change. */
|
|
72
|
-
credentialId:
|
|
73
|
+
credentialId: CredentialId;
|
|
73
74
|
newPassword: string;
|
|
74
75
|
/** The current password, proved inside the change; required while the
|
|
75
76
|
* server requires it, accepted whenever given. */
|
|
@@ -485,14 +486,14 @@ export function createAuth(loadClient: ZkppClientLoader) {
|
|
|
485
486
|
|
|
486
487
|
/**
|
|
487
488
|
* Replace the password under a verified reset: `context` is the operation
|
|
488
|
-
* the verification prepared (`VerifyPasswordResetResponse.history`)
|
|
489
|
+
* the verification prepared (`VerifyPasswordResetResponse.history`), which
|
|
490
|
+
* carries the reset's authority.
|
|
489
491
|
*/
|
|
490
492
|
async function resetPassword(
|
|
491
|
-
resetSessionId: string,
|
|
492
493
|
context: PasswordHistoryContext | undefined,
|
|
493
494
|
newPassword: string,
|
|
494
495
|
onProgress?: (p: PasswordOperationProgress) => void,
|
|
495
|
-
): Promise<
|
|
496
|
+
): Promise<PasswordResetResult> {
|
|
496
497
|
const plan = new OperationPlan(onProgress);
|
|
497
498
|
const prepared = historyContext(context);
|
|
498
499
|
const done = await install(
|
|
@@ -506,7 +507,6 @@ export function createAuth(loadClient: ZkppClientLoader) {
|
|
|
506
507
|
plan,
|
|
507
508
|
);
|
|
508
509
|
const completed = await completePasswordReset(
|
|
509
|
-
resetSessionId,
|
|
510
510
|
done.operationId,
|
|
511
511
|
done.record,
|
|
512
512
|
done.proof,
|
|
@@ -539,7 +539,7 @@ export function createAuth(loadClient: ZkppClientLoader) {
|
|
|
539
539
|
const finished = await opaqueLoginFinish(
|
|
540
540
|
identifier,
|
|
541
541
|
finalization,
|
|
542
|
-
started.
|
|
542
|
+
started.loginId,
|
|
543
543
|
);
|
|
544
544
|
return {
|
|
545
545
|
sessionId: finished.sessionId,
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
CredentialId,
|
|
4
|
+
IdError,
|
|
5
|
+
MagicLinkId,
|
|
6
|
+
MissingIdError,
|
|
7
|
+
OtpSessionId,
|
|
8
|
+
ResetSessionId,
|
|
9
|
+
SessionId,
|
|
10
|
+
StepUpChallengeId,
|
|
11
|
+
} from "@structured-id/ids";
|
|
12
|
+
import {
|
|
13
|
+
StepUpMethod,
|
|
14
|
+
completePasswordReset,
|
|
15
|
+
completeStepUp,
|
|
16
|
+
deleteMfaCredential,
|
|
17
|
+
finishTotpEnrollment,
|
|
18
|
+
requestMagicLink,
|
|
19
|
+
requestStepUp,
|
|
20
|
+
resendOtp,
|
|
21
|
+
verifyMagicLink,
|
|
22
|
+
verifyOtp,
|
|
23
|
+
verifyPasswordReset,
|
|
24
|
+
verifyTotp,
|
|
25
|
+
webAuthnAuthenticationFinish,
|
|
26
|
+
} from "./useAuthApi";
|
|
27
|
+
|
|
28
|
+
// Every AuthService method the tests call, keyed by its client method name.
|
|
29
|
+
const rpc: Record<string, ReturnType<typeof vi.fn>> = {};
|
|
30
|
+
|
|
31
|
+
vi.mock("../../index", () => ({
|
|
32
|
+
getTransport: vi.fn(),
|
|
33
|
+
getAccountTransport: vi.fn(),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
vi.mock("@structured-id/proto/sid/v1/authn/auth.client", () => ({
|
|
37
|
+
// Called with `new`: a constructor mock must be a `function`, not an arrow.
|
|
38
|
+
AuthServiceClient: vi.fn().mockImplementation(function () {
|
|
39
|
+
return new Proxy(
|
|
40
|
+
{},
|
|
41
|
+
{ get: (_target, name: string) => (rpc[name] ??= vi.fn()) },
|
|
42
|
+
);
|
|
43
|
+
}),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
vi.mock("../auth", () => ({
|
|
47
|
+
authMeta: () => ({}),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
const SESSION = SessionId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d10");
|
|
51
|
+
const CRED = CredentialId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d11");
|
|
52
|
+
const OTP = OtpSessionId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d12");
|
|
53
|
+
const LINK = MagicLinkId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d13");
|
|
54
|
+
const CHALLENGE = StepUpChallengeId.parse(
|
|
55
|
+
"0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d14",
|
|
56
|
+
);
|
|
57
|
+
const RESET = ResetSessionId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d15");
|
|
58
|
+
const OPERATION = new Uint8Array(16).fill(7);
|
|
59
|
+
const MALFORMED = { value: new Uint8Array(15) };
|
|
60
|
+
|
|
61
|
+
/** The RPC `name` answers `response` once. */
|
|
62
|
+
function answers(name: string, response: object) {
|
|
63
|
+
rpc[name] ??= vi.fn();
|
|
64
|
+
rpc[name].mockResolvedValueOnce({ response });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The request the RPC `name` was last called with. */
|
|
68
|
+
function sent(name: string): unknown {
|
|
69
|
+
return rpc[name].mock.calls.at(-1)?.[0];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
for (const fn of Object.values(rpc)) fn.mockReset();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("one-time code", () => {
|
|
77
|
+
// The code is verified against the request it was sent for, named by its
|
|
78
|
+
// binary identifier; the provisional session is read only when verified.
|
|
79
|
+
it("names the request and reads the session it opened", async () => {
|
|
80
|
+
answers("verifyOtp", { verified: true, sessionId: SESSION.toWire() });
|
|
81
|
+
const result = await verifyOtp("12345678", OTP);
|
|
82
|
+
expect(sent("verifyOtp")).toEqual({
|
|
83
|
+
code: "12345678",
|
|
84
|
+
otpSessionId: { value: OTP.toBytes() },
|
|
85
|
+
});
|
|
86
|
+
expect(result.sessionId?.equals(SESSION)).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("reads no session from a code that did not verify", async () => {
|
|
90
|
+
answers("verifyOtp", { verified: false });
|
|
91
|
+
expect((await verifyOtp("0", OTP)).sessionId).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("refuses a malformed session in a verified answer", async () => {
|
|
95
|
+
answers("verifyOtp", { verified: true, sessionId: MALFORMED });
|
|
96
|
+
await expect(verifyOtp("0", OTP)).rejects.toBeInstanceOf(IdError);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("resends for the request it names", async () => {
|
|
100
|
+
answers("resendOtp", {});
|
|
101
|
+
await resendOtp(OTP);
|
|
102
|
+
expect(sent("resendOtp")).toEqual({
|
|
103
|
+
otpSessionId: { value: OTP.toBytes() },
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("second factor", () => {
|
|
109
|
+
// A present but malformed session is an error even though the field is
|
|
110
|
+
// optional: an empty message is not an absent one.
|
|
111
|
+
it("refuses a malformed session in an MFA answer", async () => {
|
|
112
|
+
answers("verifyTotp", { verified: true, sessionId: MALFORMED });
|
|
113
|
+
await expect(verifyTotp("123456")).rejects.toBeInstanceOf(IdError);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("reads the credential a TOTP enrollment stored and refuses an answer without one", async () => {
|
|
117
|
+
answers("finishTotpEnrollment", {
|
|
118
|
+
credentialId: CRED.toWire(),
|
|
119
|
+
recoveryCodes: [],
|
|
120
|
+
});
|
|
121
|
+
expect(
|
|
122
|
+
(await finishTotpEnrollment("123456")).credentialId.equals(CRED),
|
|
123
|
+
).toBe(true);
|
|
124
|
+
answers("finishTotpEnrollment", { recoveryCodes: [] });
|
|
125
|
+
await expect(finishTotpEnrollment("123456")).rejects.toBeInstanceOf(
|
|
126
|
+
MissingIdError,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("deletes the MFA credential it names in binary", async () => {
|
|
131
|
+
answers("deleteMfaCredential", {});
|
|
132
|
+
await deleteMfaCredential(CRED);
|
|
133
|
+
expect(sent("deleteMfaCredential")).toEqual({
|
|
134
|
+
credentialId: { value: CRED.toBytes() },
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("passkey sign-in", () => {
|
|
140
|
+
// The server finds the ceremony by the challenge the assertion signs, so
|
|
141
|
+
// no state key is sent; the answer must name its session.
|
|
142
|
+
it("sends the assertion alone and reads the session", async () => {
|
|
143
|
+
answers("webAuthnAuthenticationFinish", {
|
|
144
|
+
sessionId: SESSION.toWire(),
|
|
145
|
+
expiresIn: 60n,
|
|
146
|
+
});
|
|
147
|
+
const credential = new Uint8Array([1, 2]);
|
|
148
|
+
const result = await webAuthnAuthenticationFinish(credential, "a@b.c");
|
|
149
|
+
expect(sent("webAuthnAuthenticationFinish")).toEqual({
|
|
150
|
+
credential,
|
|
151
|
+
principal: "a@b.c",
|
|
152
|
+
});
|
|
153
|
+
expect(result.sessionId.equals(SESSION)).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("refuses an answer without its session", async () => {
|
|
157
|
+
answers("webAuthnAuthenticationFinish", { expiresIn: 60n });
|
|
158
|
+
await expect(
|
|
159
|
+
webAuthnAuthenticationFinish(new Uint8Array()),
|
|
160
|
+
).rejects.toBeInstanceOf(MissingIdError);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("magic link", () => {
|
|
165
|
+
it("reads the link it requested and refuses an answer without one", async () => {
|
|
166
|
+
answers("requestMagicLink", { magicLinkId: LINK.toWire(), expiresIn: 600 });
|
|
167
|
+
expect(
|
|
168
|
+
(
|
|
169
|
+
await requestMagicLink("a@b.c", "app", "https://app/")
|
|
170
|
+
).magicLinkId.equals(LINK),
|
|
171
|
+
).toBe(true);
|
|
172
|
+
answers("requestMagicLink", { expiresIn: 600 });
|
|
173
|
+
await expect(
|
|
174
|
+
requestMagicLink("a@b.c", "app", "https://app/"),
|
|
175
|
+
).rejects.toBeInstanceOf(MissingIdError);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("verifies the link it names and reads the session it opened", async () => {
|
|
179
|
+
answers("verifyMagicLink", { sessionId: SESSION.toWire(), expiresIn: 60n });
|
|
180
|
+
const result = await verifyMagicLink(LINK, "token");
|
|
181
|
+
expect(sent("verifyMagicLink")).toEqual({
|
|
182
|
+
magicLinkId: { value: LINK.toBytes() },
|
|
183
|
+
token: "token",
|
|
184
|
+
});
|
|
185
|
+
expect(result.sessionId.equals(SESSION)).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("step-up", () => {
|
|
190
|
+
// The session stepped up is the bearer token's own: it is never named in
|
|
191
|
+
// the request, and the challenge is named by its binary identifier.
|
|
192
|
+
it("requests a challenge without naming a session", async () => {
|
|
193
|
+
answers("requestStepUp", {
|
|
194
|
+
method: StepUpMethod.WEBAUTHN,
|
|
195
|
+
challengeId: CHALLENGE.toWire(),
|
|
196
|
+
challenge: { oneofKind: undefined },
|
|
197
|
+
});
|
|
198
|
+
const result = await requestStepUp(StepUpMethod.WEBAUTHN);
|
|
199
|
+
expect(sent("requestStepUp")).toEqual({ method: StepUpMethod.WEBAUTHN });
|
|
200
|
+
expect(result.challengeId?.equals(CHALLENGE)).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("completes with the challenge in binary, or none for a 1-phase method", async () => {
|
|
204
|
+
answers("completeStepUp", {});
|
|
205
|
+
await completeStepUp(StepUpMethod.WEBAUTHN, CHALLENGE, {
|
|
206
|
+
webauthnAssertion: new Uint8Array([1]),
|
|
207
|
+
});
|
|
208
|
+
expect(sent("completeStepUp")).toMatchObject({
|
|
209
|
+
method: StepUpMethod.WEBAUTHN,
|
|
210
|
+
challengeId: { value: CHALLENGE.toBytes() },
|
|
211
|
+
});
|
|
212
|
+
expect(sent("completeStepUp")).not.toHaveProperty("sessionId");
|
|
213
|
+
|
|
214
|
+
answers("completeStepUp", {});
|
|
215
|
+
await completeStepUp(StepUpMethod.TOTP, undefined, { totpCode: "123456" });
|
|
216
|
+
expect(sent("completeStepUp")).toMatchObject({ challengeId: undefined });
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("password reset", () => {
|
|
221
|
+
it("verifies the reset it names in binary", async () => {
|
|
222
|
+
answers("verifyPasswordReset", { expiresIn: 600 });
|
|
223
|
+
await verifyPasswordReset(RESET, "token");
|
|
224
|
+
expect(sent("verifyPasswordReset")).toEqual({
|
|
225
|
+
resetSessionId: { value: RESET.toBytes() },
|
|
226
|
+
token: "token",
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// The completion is named by its operation, which carries the verified
|
|
231
|
+
// reset's authority; the session it may open is optional.
|
|
232
|
+
it("completes by the operation alone and reads the optional session", async () => {
|
|
233
|
+
answers("completePasswordReset", { sessionId: SESSION.toWire() });
|
|
234
|
+
const record = new Uint8Array([1]);
|
|
235
|
+
const done = await completePasswordReset(OPERATION, record);
|
|
236
|
+
expect(sent("completePasswordReset")).toEqual({
|
|
237
|
+
operationId: { value: OPERATION },
|
|
238
|
+
registrationRecord: record,
|
|
239
|
+
proof: undefined,
|
|
240
|
+
});
|
|
241
|
+
expect(done.sessionId?.equals(SESSION)).toBe(true);
|
|
242
|
+
|
|
243
|
+
answers("completePasswordReset", {});
|
|
244
|
+
expect(
|
|
245
|
+
(await completePasswordReset(OPERATION, record)).sessionId,
|
|
246
|
+
).toBeUndefined();
|
|
247
|
+
});
|
|
248
|
+
});
|