@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structured-id/ui-core",
3
- "version": "4.12.0",
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/proto": "^0.9.0",
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 === session.id"
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 { Session } from "@structured-id/proto/sid/v1/identity/identity";
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: string | null;
60
- currentSessionId?: string;
60
+ revoking: SessionId | null;
61
+ currentSessionId?: SessionId;
61
62
  }>();
62
63
 
63
64
  defineEmits<{
64
- revoke: [sessionId: string];
65
+ revoke: [sessionId: SessionId];
65
66
  }>();
66
67
 
67
68
  function isCurrentSession(session: Session): boolean {
68
- return false; // Overridden via currentSessionId prop comparison
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?: string;
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?: string }];
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
- serverLoginState: "server-state",
128
+ loginId: LOGIN,
125
129
  });
126
130
  api.loginFinish.mockResolvedValue({
127
- sessionId: "s",
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: "cred-uuid",
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: "cred-uuid" });
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: "cred-1",
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("cred-1", START.request);
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
- "cred-1",
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: "cred-1", newPassword: "N3wP@ssword!" },
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: "cred-1", newPassword: "N3wP@ssword!" },
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: "cred-1",
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
- "cred-1",
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
- "cred-1",
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: "cred-1",
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: "cred-1",
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: "cred-1",
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: "cred-1",
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: "cred-1",
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: "cred-1",
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: "cred-1", newPassword: "pw" }),
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: "cred-1", newPassword: "pw" }),
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: "sess-1",
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("reset-1", wireContext(), "N3wP@ssword!");
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("sess-1");
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("reset-1", undefined, "pw")).rejects.toThrow(
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
- serverLoginState: "server-state",
689
+ loginId: LOGIN,
689
690
  });
690
691
  api.loginFinish.mockResolvedValue({
691
- sessionId: "sess-1",
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
- "server-state",
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: "sess-1",
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: string;
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: string;
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: string;
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<CompletePasswordResetResponse> {
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.serverLoginState,
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
+ });