@structured-id/ui-core 4.14.2 → 4.15.1

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.14.2",
3
+ "version": "4.15.1",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -42,8 +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/ids": "^0.7.0",
46
- "@structured-id/proto": "^0.14.0",
45
+ "@structured-id/ids": "^0.10.0",
46
+ "@structured-id/proto": "^0.18.0",
47
47
  "@structured-world/structured-email-address-ts": "^0.0.22",
48
48
  "@structured-world/structured-public-domains": "0.0.x",
49
49
  "libphonenumber-js": "^1.13.15"
package/src/index.ts CHANGED
@@ -82,6 +82,7 @@ export {
82
82
  refusalMessage,
83
83
  REFUSAL_TEXTS,
84
84
  SID_ERROR_DOMAIN,
85
+ SignInRefusedError,
85
86
  type FieldViolation,
86
87
  type RpcRefusal,
87
88
  } from "./utils/rpcRefusal";
@@ -120,7 +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
+ import type { LoginSuccessResult } from "../composables/useLoginSuccess";
124
124
  import {
125
125
  ALL_PRINCIPAL_TYPES,
126
126
  DEFAULT_PRINCIPAL_INPUT_TEXTS,
@@ -175,10 +175,7 @@ const props = withDefaults(
175
175
  identifier: string,
176
176
  password: string,
177
177
  onStep?: (step: number) => void,
178
- ) => Promise<{
179
- expiresIn: number;
180
- sessionId?: SessionId;
181
- }>;
178
+ ) => Promise<LoginSuccessResult & { expiresIn: number }>;
182
179
  }>(),
183
180
  {
184
181
  title: "Sign in",
@@ -207,7 +204,7 @@ const props = withDefaults(
207
204
  );
208
205
 
209
206
  const emit = defineEmits<{
210
- success: [result: { expiresIn: number; sessionId?: SessionId }];
207
+ success: [result: LoginSuccessResult & { expiresIn: number }];
211
208
  error: [error: Error];
212
209
  "forgot-password": [];
213
210
  }>();
@@ -7,6 +7,7 @@ import {
7
7
  type PasswordOperationProgress,
8
8
  WrongCurrentPasswordError,
9
9
  } from "./useAuth";
10
+ import { SignInRefusedError } from "../../utils/rpcRefusal";
10
11
 
11
12
  // Mock the gRPC wrappers
12
13
  vi.mock("./useAuthApi", () => ({
@@ -132,6 +133,7 @@ describe("createAuth prewarm", () => {
132
133
  expiresIn: 60n,
133
134
  secondFactors: [],
134
135
  enrollableFactors: [],
136
+ pending: null,
135
137
  });
136
138
  });
137
139
 
@@ -693,6 +695,7 @@ describe("createAuth login", () => {
693
695
  expiresIn: BigInt(3600),
694
696
  secondFactors: [],
695
697
  enrollableFactors: [],
698
+ pending: null,
696
699
  });
697
700
  });
698
701
 
@@ -725,17 +728,46 @@ describe("createAuth login", () => {
725
728
  expect(result).toEqual({
726
729
  sessionId: SESSION,
727
730
  expiresIn: 3600,
731
+ pending: null,
728
732
  });
729
733
  });
730
734
 
731
- it("propagates a wrong password (the client cannot finish)", async () => {
732
- vi.mocked(client.loginFinish).mockRejectedValue(
733
- new Error("login finish: InvalidLoginError"),
734
- );
735
+ // A sign-in owing its second factor states it, for the page to continue
736
+ // to the factor instead of into the app.
737
+ it("states the second factor the sign-in owes", async () => {
738
+ const pending = { secondFactors: [1], enrollableFactors: [] };
739
+ api.loginFinish.mockResolvedValue({
740
+ sessionId: SESSION,
741
+ expiresIn: BigInt(300),
742
+ secondFactors: [1],
743
+ enrollableFactors: [],
744
+ pending,
745
+ });
735
746
  const { login } = createAuth(loader);
736
- await expect(login("alice@test.com", "wrong")).rejects.toThrow(
737
- "InvalidLoginError",
738
- );
747
+ const result = await login("alice@test.com", "pw");
748
+ expect(result.pending).toEqual(pending);
749
+ });
750
+
751
+ // A wrong password showed the client's own "invalid login" to the user; it
752
+ // is the sign-in's refusal, and nothing more is sent.
753
+ it("reports a wrong password as a refused sign-in", async () => {
754
+ const refused = new Error("invalid login");
755
+ refused.name = "ZkppInvalidLoginError";
756
+ vi.mocked(client.loginFinish).mockRejectedValue(refused);
757
+ const { login } = createAuth(loader);
758
+ const failure = await login("alice@test.com", "wrong").catch((e) => e);
759
+ expect(failure).toBeInstanceOf(SignInRefusedError);
760
+ expect((failure as SignInRefusedError).cause).toBe(refused);
761
+ expect(api.loginFinish).not.toHaveBeenCalled();
762
+ });
763
+
764
+ // Only the client's refusal judged the password: a malformed response or
765
+ // another fault comes through as itself.
766
+ it("reports a sign-in failure other than a refusal as itself", async () => {
767
+ const fault = new Error("response: invalid length");
768
+ vi.mocked(client.loginFinish).mockRejectedValue(fault);
769
+ const { login } = createAuth(loader);
770
+ await expect(login("alice@test.com", "pw")).rejects.toBe(fault);
739
771
  expect(api.loginFinish).not.toHaveBeenCalled();
740
772
  });
741
773
  });
@@ -15,6 +15,8 @@
15
15
  * gRPC calls go through useAuthApi wrappers.
16
16
  */
17
17
  import type { CredentialId, SessionId } from "@structured-id/ids";
18
+ import type { PendingSignIn } from "../pendingSignIn";
19
+ import { SignInRefusedError } from "../../utils/rpcRefusal";
18
20
  import {
19
21
  completePasswordReset,
20
22
  evaluatePasswordHistory,
@@ -42,6 +44,8 @@ export interface LoginResult {
42
44
  sessionId: SessionId;
43
45
  /** Seconds until the delivered credential expires. */
44
46
  expiresIn: number;
47
+ /** The second factor the sign-in still owes; null when it is complete. */
48
+ pending: PendingSignIn | null;
45
49
  }
46
50
 
47
51
  /**
@@ -530,12 +534,20 @@ export function createAuth(loadClient: ZkppClientLoader) {
530
534
  const started = await opaqueLoginStart(identifier, start.request);
531
535
  onStep?.(3);
532
536
  // An ordinary sign-in runs under the empty context.
533
- const finalization = await c.loginFinish(
534
- password,
535
- start.state,
536
- started.credentialResponse,
537
- new Uint8Array(),
538
- );
537
+ let finalization: Uint8Array;
538
+ try {
539
+ finalization = await c.loginFinish(
540
+ password,
541
+ start.state,
542
+ started.credentialResponse,
543
+ new Uint8Array(),
544
+ );
545
+ } catch (e) {
546
+ // The client's refusal is the sign-in's answer; any other failure (a
547
+ // stopped client, a malformed response) is itself.
548
+ if (isRefusedSignIn(e)) throw new SignInRefusedError(e);
549
+ throw e;
550
+ }
539
551
  const finished = await opaqueLoginFinish(
540
552
  identifier,
541
553
  finalization,
@@ -544,6 +556,7 @@ export function createAuth(loadClient: ZkppClientLoader) {
544
556
  return {
545
557
  sessionId: finished.sessionId,
546
558
  expiresIn: Number(finished.expiresIn),
559
+ pending: finished.pending,
547
560
  };
548
561
  }
549
562
 
@@ -143,6 +143,8 @@ describe("passkey sign-in", () => {
143
143
  answers("webAuthnAuthenticationFinish", {
144
144
  sessionId: SESSION.toWire(),
145
145
  expiresIn: 60n,
146
+ secondFactors: [],
147
+ enrollableFactors: [],
146
148
  });
147
149
  const credential = new Uint8Array([1, 2]);
148
150
  const result = await webAuthnAuthenticationFinish(credential, "a@b.c");
@@ -94,7 +94,12 @@ describe("OPAQUE gRPC wrappers", () => {
94
94
  describe("opaqueLoginFinish", () => {
95
95
  it("sends principal, finalization and the login's bytes, reads the session", async () => {
96
96
  mockOpaqueLoginFinish.mockResolvedValue({
97
- response: { sessionId: SESSION.toWire(), expiresIn: BigInt(3600) },
97
+ response: {
98
+ sessionId: SESSION.toWire(),
99
+ expiresIn: BigInt(3600),
100
+ secondFactors: [],
101
+ enrollableFactors: [],
102
+ },
98
103
  });
99
104
 
100
105
  const result = await opaqueLoginFinish(
@@ -110,6 +115,7 @@ describe("OPAQUE gRPC wrappers", () => {
110
115
  });
111
116
  expect(result.sessionId.equals(SESSION)).toBe(true);
112
117
  expect(result.expiresIn).toBe(BigInt(3600));
118
+ expect(result.pending).toBeNull();
113
119
  });
114
120
 
115
121
  // Every completed sign-in names its session; an answer without one is
@@ -0,0 +1,134 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { RpcError } from "@protobuf-ts/runtime-rpc";
3
+ import { OpaqueLoginId, SessionId } from "@structured-id/ids";
4
+ import {
5
+ StepUpMethod,
6
+ opaqueLoginFinish,
7
+ startTotpEnrollment,
8
+ verifyRecoveryCode,
9
+ verifyTotp,
10
+ } from "./useAuthApi";
11
+ import { endPendingSignIn, pendingSignIn } from "../pendingSignIn";
12
+
13
+ // The AuthService methods by client method name, separately for the sign-in
14
+ // surface and the account API.
15
+ const rpc: Record<string, Record<string, ReturnType<typeof vi.fn>>> = {
16
+ signIn: {},
17
+ account: {},
18
+ };
19
+
20
+ vi.mock("../../index", () => ({
21
+ getTransport: vi.fn(() => "signIn"),
22
+ getAccountTransport: vi.fn(() => "account"),
23
+ }));
24
+
25
+ vi.mock("@structured-id/proto/sid/v1/authn/auth.client", () => ({
26
+ // Called with `new`: a constructor mock must be a `function`, not an arrow.
27
+ AuthServiceClient: vi.fn().mockImplementation(function (surface: string) {
28
+ const methods = rpc[surface];
29
+ return new Proxy(
30
+ {},
31
+ { get: (_target, name: string) => (methods[name] ??= vi.fn()) },
32
+ );
33
+ }),
34
+ }));
35
+
36
+ vi.mock("../auth", () => ({
37
+ authMeta: () => ({}),
38
+ }));
39
+
40
+ const SESSION = SessionId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d10");
41
+ const LOGIN = OpaqueLoginId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d16");
42
+
43
+ function call(surface: "signIn" | "account", name: string) {
44
+ return (rpc[surface][name] ??= vi.fn());
45
+ }
46
+
47
+ /** The options the RPC `name` was last called with. */
48
+ function metaOf(surface: "signIn" | "account", name: string): unknown {
49
+ return call(surface, name).mock.calls.at(-1)?.[1]?.meta;
50
+ }
51
+
52
+ async function signInOwingTotp() {
53
+ call("signIn", "opaqueLoginFinish").mockResolvedValueOnce({
54
+ response: {
55
+ sessionId: SESSION.toWire(),
56
+ expiresIn: 300n,
57
+ secondFactors: [StepUpMethod.TOTP],
58
+ enrollableFactors: [],
59
+ browserCsrf: "csrf-1",
60
+ },
61
+ });
62
+ return opaqueLoginFinish("alice", new Uint8Array(), LOGIN);
63
+ }
64
+
65
+ beforeEach(() => {
66
+ endPendingSignIn();
67
+ sessionStorage.clear();
68
+ for (const surface of Object.values(rpc)) {
69
+ for (const fn of Object.values(surface)) fn.mockReset();
70
+ }
71
+ });
72
+
73
+ describe("a browser sign-in owing its second factor", () => {
74
+ // Regression: the answer's factors and CSRF token were dropped, so the
75
+ // completion went without the token and the server refused it.
76
+ it("is kept from the password sign-in's answer", async () => {
77
+ const finished = await signInOwingTotp();
78
+ expect(finished.pending).toEqual({
79
+ secondFactors: [StepUpMethod.TOTP],
80
+ enrollableFactors: [],
81
+ });
82
+ expect(pendingSignIn()).toEqual(finished.pending);
83
+ });
84
+
85
+ it("sends its CSRF token with the completion and ends with the session", async () => {
86
+ await signInOwingTotp();
87
+ call("signIn", "verifyTotp").mockResolvedValueOnce({
88
+ response: { verified: true, sessionId: SESSION.toWire() },
89
+ });
90
+
91
+ await verifyTotp("123456");
92
+
93
+ expect(metaOf("signIn", "verifyTotp")).toEqual({
94
+ "x-csrf-token": "csrf-1",
95
+ });
96
+ expect(pendingSignIn()).toBeNull();
97
+ });
98
+
99
+ // An unknown outcome may have committed: the record stays, so the same
100
+ // completion is repeated with the same token and receives the sign-in.
101
+ it("keeps the record when the completion's outcome is unknown", async () => {
102
+ await signInOwingTotp();
103
+ call("signIn", "verifyRecoveryCode").mockRejectedValueOnce(
104
+ new RpcError("lost", "UNAVAILABLE"),
105
+ );
106
+
107
+ await expect(verifyRecoveryCode("AAAA-BBBB-CCCC")).rejects.toThrow();
108
+
109
+ expect(pendingSignIn()).not.toBeNull();
110
+ expect(metaOf("signIn", "verifyRecoveryCode")).toEqual({
111
+ "x-csrf-token": "csrf-1",
112
+ });
113
+ });
114
+
115
+ // The first TOTP enrollment of a sign-in goes to the sign-in surface with
116
+ // the pending token; from the account pages it stays on the account API.
117
+ it("enrolls its first factor on the sign-in surface", async () => {
118
+ await signInOwingTotp();
119
+ call("signIn", "startTotpEnrollment").mockResolvedValueOnce({
120
+ response: {},
121
+ });
122
+ call("account", "startTotpEnrollment").mockResolvedValueOnce({
123
+ response: {},
124
+ });
125
+
126
+ await startTotpEnrollment("sign-in");
127
+ await startTotpEnrollment();
128
+
129
+ expect(metaOf("signIn", "startTotpEnrollment")).toEqual({
130
+ "x-csrf-token": "csrf-1",
131
+ });
132
+ expect(metaOf("account", "startTotpEnrollment")).toEqual({});
133
+ });
134
+ });
@@ -17,6 +17,7 @@ import {
17
17
  CredentialId,
18
18
  MagicLinkId,
19
19
  OpaqueLoginId,
20
+ OrgId,
20
21
  OtpSessionId,
21
22
  ResetSessionId,
22
23
  SessionId,
@@ -75,6 +76,12 @@ import type {
75
76
  } from "@structured-id/proto/sid/v1/authn/password_history";
76
77
  import { PasswordHistoryEvaluatorServiceClient } from "@structured-id/proto/sid/v1/authn/password_history.client";
77
78
  import { authMeta } from "../auth";
79
+ import {
80
+ endPendingSignIn,
81
+ holdPendingSignIn,
82
+ pendingSignInMeta,
83
+ type PendingSignIn,
84
+ } from "../pendingSignIn";
78
85
 
79
86
  /** The history evaluator: in CE served beside the credential service. */
80
87
  function evaluator(): PasswordHistoryEvaluatorServiceClient {
@@ -105,11 +112,26 @@ export type WithId<T, K extends keyof T, V> = Omit<T, K> & { [P in K]: V };
105
112
 
106
113
  // ── MFA TOTP ──
107
114
 
108
- export async function startTotpEnrollment(): Promise<TotpEnrollmentChallenge> {
109
- const { response } = await account().startTotpEnrollment(
110
- {},
111
- { meta: authMeta() },
112
- );
115
+ /**
116
+ * Who enrolls a factor: the signed-in user from the account pages, or the
117
+ * pending sign-in enrolling its first factor on the sign-in surface.
118
+ */
119
+ export type Enrolling = "account" | "sign-in";
120
+
121
+ function enrollmentClient(by: Enrolling): {
122
+ client: AuthServiceClient;
123
+ meta: Record<string, string>;
124
+ } {
125
+ return by === "sign-in"
126
+ ? { client: client(), meta: signInMeta() }
127
+ : { client: account(), meta: authMeta() };
128
+ }
129
+
130
+ export async function startTotpEnrollment(
131
+ by: Enrolling = "account",
132
+ ): Promise<TotpEnrollmentChallenge> {
133
+ const { client: enroller, meta } = enrollmentClient(by);
134
+ const { response } = await enroller.startTotpEnrollment({}, { meta });
113
135
  return response;
114
136
  }
115
137
 
@@ -119,13 +141,16 @@ export type TotpEnrollmentResult = WithId<
119
141
  CredentialId
120
142
  >;
121
143
 
144
+ /**
145
+ * Finish the TOTP enrollment with its first code. A sign-in that enrolled
146
+ * its first factor then completes with a fresh code ({@link verifyTotp}).
147
+ */
122
148
  export async function finishTotpEnrollment(
123
149
  code: string,
150
+ by: Enrolling = "account",
124
151
  ): Promise<TotpEnrollmentResult> {
125
- const { response } = await account().finishTotpEnrollment(
126
- { code },
127
- { meta: authMeta() },
128
- );
152
+ const { client: enroller, meta } = enrollmentClient(by);
153
+ const { response } = await enroller.finishTotpEnrollment({ code }, { meta });
129
154
  return {
130
155
  ...response,
131
156
  credentialId: required(CredentialId, response.credentialId),
@@ -139,14 +164,26 @@ export type MfaVerifyResult = WithId<
139
164
  SessionId | undefined
140
165
  >;
141
166
 
167
+ /**
168
+ * A verification's answer. The session it names ends the pending sign-in
169
+ * the verification completed; any failure keeps it, so the same completion
170
+ * can be repeated.
171
+ */
142
172
  function mfaVerified(response: MfaVerifyResponse): MfaVerifyResult {
143
- return { ...response, sessionId: optional(SessionId, response.sessionId) };
173
+ const sessionId = optional(SessionId, response.sessionId);
174
+ if (sessionId) endPendingSignIn();
175
+ return { ...response, sessionId };
176
+ }
177
+
178
+ /** Metadata of a call that completes the pending sign-in, if this tab has one. */
179
+ function signInMeta(): Record<string, string> {
180
+ return { ...authMeta(), ...pendingSignInMeta() };
144
181
  }
145
182
 
146
183
  export async function verifyTotp(code: string): Promise<MfaVerifyResult> {
147
184
  const { response } = await client().verifyTotp(
148
185
  { code },
149
- { meta: authMeta() },
186
+ { meta: signInMeta() },
150
187
  );
151
188
  return mfaVerified(response);
152
189
  }
@@ -156,13 +193,13 @@ export async function verifyTotp(code: string): Promise<MfaVerifyResult> {
156
193
  export async function verifySmsMfa(code: string): Promise<MfaVerifyResult> {
157
194
  const { response } = await client().verifySmsMfa(
158
195
  { code },
159
- { meta: authMeta() },
196
+ { meta: signInMeta() },
160
197
  );
161
198
  return mfaVerified(response);
162
199
  }
163
200
 
164
201
  export async function resendSmsMfa(): Promise<ResendSmsMfaResponse> {
165
- const { response } = await client().resendSmsMfa({}, { meta: authMeta() });
202
+ const { response } = await client().resendSmsMfa({}, { meta: signInMeta() });
166
203
  return response;
167
204
  }
168
205
 
@@ -181,7 +218,7 @@ export async function verifyRecoveryCode(
181
218
  ): Promise<MfaVerifyResult> {
182
219
  const { response } = await client().verifyRecoveryCode(
183
220
  { code },
184
- { meta: authMeta() },
221
+ { meta: signInMeta() },
185
222
  );
186
223
  return mfaVerified(response);
187
224
  }
@@ -264,11 +301,12 @@ export type PasskeySignInResult = WithId<
264
301
  WebAuthnAuthenticationFinishResponse,
265
302
  "sessionId",
266
303
  SessionId
267
- >;
304
+ > & { pending: PendingSignIn | null };
268
305
 
269
306
  /**
270
307
  * Finish a passkey sign-in with the assertion; the server finds the ceremony
271
- * by the challenge the assertion signs.
308
+ * by the challenge the assertion signs. `pending` is the second factor the
309
+ * sign-in still owes, kept for its completion.
272
310
  */
273
311
  export async function webAuthnAuthenticationFinish(
274
312
  credential: Uint8Array,
@@ -278,7 +316,11 @@ export async function webAuthnAuthenticationFinish(
278
316
  { credential, principal: principal ?? "" },
279
317
  { meta: authMeta() },
280
318
  );
281
- return { ...response, sessionId: required(SessionId, response.sessionId) };
319
+ return {
320
+ ...response,
321
+ sessionId: required(SessionId, response.sessionId),
322
+ pending: holdPendingSignIn(response),
323
+ };
282
324
  }
283
325
 
284
326
  export async function webAuthnRegistrationStart(
@@ -425,19 +467,26 @@ export async function confirmIdpLink(
425
467
 
426
468
  // ── Organization Selector ──
427
469
 
428
- export async function listUserOrganizations(): Promise<ListUserOrganizationsResponse> {
470
+ /** An organization the sign-in may continue in, named by its typed id. */
471
+ export type UserOrganizationRef = WithId<UserOrganization, "id", OrgId>;
472
+
473
+ /** The organizations the signed-in user may continue in. */
474
+ export async function listUserOrganizations(): Promise<UserOrganizationRef[]> {
429
475
  const { response } = await client().listUserOrganizations(
430
476
  {},
431
477
  { meta: authMeta() },
432
478
  );
433
- return response;
479
+ return response.organizations.map((organization) => ({
480
+ ...organization,
481
+ id: required(OrgId, organization.id),
482
+ }));
434
483
  }
435
484
 
436
485
  export async function selectOrganization(
437
- organizationId: string,
486
+ organizationId: OrgId,
438
487
  ): Promise<SelectOrganizationResponse> {
439
488
  const { response } = await client().selectOrganization(
440
- { organizationId },
489
+ { organizationId: organizationId.toWire() },
441
490
  { meta: authMeta() },
442
491
  );
443
492
  return response;
@@ -848,9 +897,13 @@ export type LoginFinishResult = WithId<
848
897
  OpaqueLoginFinishResponse,
849
898
  "sessionId",
850
899
  SessionId
851
- >;
900
+ > & { pending: PendingSignIn | null };
852
901
 
853
- /** Finish the login `loginId` (from its start) with the client's KE3. */
902
+ /**
903
+ * Finish the login `loginId` (from its start) with the client's KE3.
904
+ * `pending` is the second factor the sign-in still owes, kept for its
905
+ * completion.
906
+ */
854
907
  export async function opaqueLoginFinish(
855
908
  principal: string,
856
909
  credentialFinalization: Uint8Array,
@@ -861,7 +914,11 @@ export async function opaqueLoginFinish(
861
914
  credentialFinalization,
862
915
  loginId: loginId.toWire(),
863
916
  });
864
- return { ...response, sessionId: required(SessionId, response.sessionId) };
917
+ return {
918
+ ...response,
919
+ sessionId: required(SessionId, response.sessionId),
920
+ pending: holdPendingSignIn(response),
921
+ };
865
922
  }
866
923
 
867
924
  // Re-export types for convenience
@@ -2,7 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
  import { setActivePinia, createPinia } from "pinia";
3
3
  import { createApp, defineComponent } from "vue";
4
4
  import { createRouter, createMemoryHistory } from "vue-router";
5
- import { authorizationContinuation, useLoginSuccess } from "./useLoginSuccess";
5
+ import { StepUpMethod } from "@structured-id/proto/sid/v1/authn/auth";
6
+ import {
7
+ SECOND_FACTOR_PATH,
8
+ authorizationContinuation,
9
+ useLoginSuccess,
10
+ } from "./useLoginSuccess";
6
11
  import { useAuthStore } from "../../stores/auth";
7
12
 
8
13
  // Mock checkBffSession from BFF composable
@@ -26,6 +31,7 @@ async function withApp<T>(
26
31
  { path: "/login", component: { template: "<div />" } },
27
32
  { path: "/account", component: { template: "<div />" } },
28
33
  { path: "/admin", component: { template: "<div />" } },
34
+ { path: SECOND_FACTOR_PATH, component: { template: "<div />" } },
29
35
  ],
30
36
  });
31
37
  await router.push(at);
@@ -190,6 +196,27 @@ describe("useLoginSuccess (BFF cookie mode)", () => {
190
196
  expect(router.currentRoute.value.path).toBe("/account");
191
197
  });
192
198
 
199
+ // Regression: a sign-in that still owes its second factor went into the
200
+ // app (or back to the authorization request) as if signed in. It goes to
201
+ // the second-factor page instead, carrying the request it waits for, and
202
+ // reads no app session.
203
+ it("continues a sign-in owing its second factor to the factor's page", async () => {
204
+ const leaveTo = vi.fn();
205
+ const { result, router } = await withApp(
206
+ () => useLoginSuccess("/account", { issuerOrigin: ISSUER, leaveTo }),
207
+ `/login?rd=${encodeURIComponent(CONTINUATION)}`,
208
+ );
209
+
210
+ await result.onLoginSuccess({
211
+ pending: { secondFactors: [StepUpMethod.TOTP], enrollableFactors: [] },
212
+ });
213
+
214
+ expect(router.currentRoute.value.path).toBe(SECOND_FACTOR_PATH);
215
+ expect(router.currentRoute.value.query.rd).toBe(CONTINUATION);
216
+ expect(leaveTo).not.toHaveBeenCalled();
217
+ expect(mockCheckBffSession).not.toHaveBeenCalled();
218
+ });
219
+
193
220
  it("navigates when BFF session returns null (cookie not yet set)", async () => {
194
221
  mockCheckBffSession.mockResolvedValue(null);
195
222
 
@@ -14,13 +14,24 @@ import type { SessionId } from "@structured-id/ids";
14
14
  import { useRouter } from "vue-router";
15
15
  import { useAuthStore } from "../../index";
16
16
  import { checkBffSession } from "../../index";
17
+ import type { PendingSignIn } from "../pendingSignIn";
17
18
 
18
19
  export interface LoginSuccessResult {
19
20
  sessionId?: SessionId;
21
+ /** The second factor the sign-in still owes; absent or null when complete. */
22
+ pending?: PendingSignIn | null;
20
23
  }
21
24
 
25
+ /**
26
+ * The app's page that completes a sign-in owing its second factor, reading
27
+ * what it owes from `pendingSignIn()`.
28
+ */
29
+ export const SECOND_FACTOR_PATH = "/account/login/mfa";
30
+
22
31
  /** Options of {@link useLoginSuccess}. */
23
32
  export interface LoginSuccessOptions {
33
+ /** The app's second-factor page; {@link SECOND_FACTOR_PATH} by default. */
34
+ secondFactorPath?: string;
24
35
  /**
25
36
  * Origin of the SID installation whose issuers may send users to this
26
37
  * sign-in page (e.g. `https://sid.example.com`). Without it, `rd` is never
@@ -96,7 +107,17 @@ export function useLoginSuccess(
96
107
  const auth = useAuthStore();
97
108
  const router = useRouter();
98
109
 
99
- async function onLoginSuccess(_result?: LoginSuccessResult) {
110
+ async function onLoginSuccess(result?: LoginSuccessResult) {
111
+ if (result?.pending) {
112
+ // Not signed in yet: the second factor completes the sign-in, and the
113
+ // authorization request it waits for goes along.
114
+ const rd = router.currentRoute.value.query.rd;
115
+ await router.push({
116
+ path: options.secondFactorPath ?? SECOND_FACTOR_PATH,
117
+ query: typeof rd === "string" ? { rd } : {},
118
+ });
119
+ return;
120
+ }
100
121
  const back = options.issuerOrigin
101
122
  ? authorizationContinuation(
102
123
  router.currentRoute.value.query.rd,