@structured-id/ui-core 4.14.1 → 4.15.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.14.1",
3
+ "version": "4.15.0",
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.4.0",
46
- "@structured-id/proto": "^0.12.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"
@@ -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
  }>();
@@ -132,6 +132,7 @@ describe("createAuth prewarm", () => {
132
132
  expiresIn: 60n,
133
133
  secondFactors: [],
134
134
  enrollableFactors: [],
135
+ pending: null,
135
136
  });
136
137
  });
137
138
 
@@ -693,6 +694,7 @@ describe("createAuth login", () => {
693
694
  expiresIn: BigInt(3600),
694
695
  secondFactors: [],
695
696
  enrollableFactors: [],
697
+ pending: null,
696
698
  });
697
699
  });
698
700
 
@@ -725,9 +727,26 @@ describe("createAuth login", () => {
725
727
  expect(result).toEqual({
726
728
  sessionId: SESSION,
727
729
  expiresIn: 3600,
730
+ pending: null,
728
731
  });
729
732
  });
730
733
 
734
+ // A sign-in owing its second factor states it, for the page to continue
735
+ // to the factor instead of into the app.
736
+ it("states the second factor the sign-in owes", async () => {
737
+ const pending = { secondFactors: [1], enrollableFactors: [] };
738
+ api.loginFinish.mockResolvedValue({
739
+ sessionId: SESSION,
740
+ expiresIn: BigInt(300),
741
+ secondFactors: [1],
742
+ enrollableFactors: [],
743
+ pending,
744
+ });
745
+ const { login } = createAuth(loader);
746
+ const result = await login("alice@test.com", "pw");
747
+ expect(result.pending).toEqual(pending);
748
+ });
749
+
731
750
  it("propagates a wrong password (the client cannot finish)", async () => {
732
751
  vi.mocked(client.loginFinish).mockRejectedValue(
733
752
  new Error("login finish: InvalidLoginError"),
@@ -15,6 +15,7 @@
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";
18
19
  import {
19
20
  completePasswordReset,
20
21
  evaluatePasswordHistory,
@@ -42,6 +43,8 @@ export interface LoginResult {
42
43
  sessionId: SessionId;
43
44
  /** Seconds until the delivered credential expires. */
44
45
  expiresIn: number;
46
+ /** The second factor the sign-in still owes; null when it is complete. */
47
+ pending: PendingSignIn | null;
45
48
  }
46
49
 
47
50
  /**
@@ -544,6 +547,7 @@ export function createAuth(loadClient: ZkppClientLoader) {
544
547
  return {
545
548
  sessionId: finished.sessionId,
546
549
  expiresIn: Number(finished.expiresIn),
550
+ pending: finished.pending,
547
551
  };
548
552
  }
549
553
 
@@ -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,
@@ -9,18 +9,24 @@ import {
9
9
  useUpstreamCallback,
10
10
  type UpstreamApi,
11
11
  type UpstreamBrowser,
12
+ type UpstreamOutcome,
12
13
  } from "./useUpstreamSignIn";
13
14
  import { UPSTREAM_CALLBACK_PATH, peekUpstream } from "../upstream";
15
+ import { SECOND_FACTOR_PATH } from "./useLoginSuccess";
16
+ import { endPendingSignIn, holdPendingSignIn } from "../pendingSignIn";
17
+ import { StepUpMethod } from "@structured-id/proto/sid/v1/authn/auth";
18
+ import { UpstreamProviderId } from "@structured-id/ids";
14
19
 
15
20
  vi.mock("../../composables/useBffAuth", () => ({
16
21
  checkBffSession: vi.fn().mockResolvedValue(null),
17
22
  }));
18
23
 
24
+ const P1 = UpstreamProviderId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d20");
19
25
  const ORIGIN = "https://ee.sid.example.com";
20
26
  const ISSUER = "https://sid.example.com";
21
27
  const RD = `${ISSUER}/i/0123456789abcdef0123456789abcdef/oauth2/authorize?client_id=ops&request_uri=${encodeURIComponent("urn:ietf:params:oauth:request_uri:r")}`;
22
28
 
23
- function api(outcome: "signed-in" | "linking-required" = "signed-in") {
29
+ function api(outcome: UpstreamOutcome = "signed-in") {
24
30
  return {
25
31
  list: vi.fn().mockResolvedValue([]),
26
32
  initiate: vi.fn().mockResolvedValue({
@@ -43,7 +49,10 @@ function browser(): UpstreamBrowser & { left: string[] } {
43
49
  };
44
50
  }
45
51
 
46
- beforeEach(() => sessionStorage.clear());
52
+ beforeEach(() => {
53
+ endPendingSignIn();
54
+ sessionStorage.clear();
55
+ });
47
56
 
48
57
  describe("startUpstream", () => {
49
58
  // The issuer starts the sign-in for this origin's exact callback page; the
@@ -51,18 +60,17 @@ describe("startUpstream", () => {
51
60
  it("starts at the issuer and leaves for the provider", async () => {
52
61
  const calls = api();
53
62
  const page = browser();
54
- await startUpstream("p1", RD, calls, page);
63
+ await startUpstream(P1, RD, calls, page);
55
64
  expect(calls.initiate).toHaveBeenCalledWith(
56
- "p1",
65
+ P1,
57
66
  `${ORIGIN}${UPSTREAM_CALLBACK_PATH}`,
58
67
  );
59
68
  expect(page.left).toEqual([
60
69
  "https://central.sid.example.com/i/h/oauth2/authorize?x=1",
61
70
  ]);
62
- expect(peekUpstream(sessionStorage, "s1")).toEqual({
63
- provider: "p1",
64
- rd: RD,
65
- });
71
+ const kept = peekUpstream(sessionStorage, "s1");
72
+ expect(kept?.provider.toString()).toBe(P1.toString());
73
+ expect(kept?.rd).toBe(RD);
66
74
  });
67
75
 
68
76
  // A refused start keeps nothing and leaves nowhere.
@@ -70,9 +78,7 @@ describe("startUpstream", () => {
70
78
  const calls = api();
71
79
  calls.initiate.mockRejectedValue(new Error("refused"));
72
80
  const page = browser();
73
- await expect(startUpstream("p1", RD, calls, page)).rejects.toThrow(
74
- "refused",
75
- );
81
+ await expect(startUpstream(P1, RD, calls, page)).rejects.toThrow("refused");
76
82
  expect(page.left).toEqual([]);
77
83
  expect(sessionStorage.length).toBe(0);
78
84
  });
@@ -84,7 +90,7 @@ describe("abandonUpstream", () => {
84
90
  // nothing is left behind.
85
91
  it("takes the kept request of a refused return", async () => {
86
92
  const page = browser();
87
- await startUpstream("p1", RD, api(), page);
93
+ await startUpstream(P1, RD, api(), page);
88
94
  expect(abandonUpstream("s1", page)).toBe(RD);
89
95
  expect(peekUpstream(sessionStorage, "s1")).toBeUndefined();
90
96
  expect(abandonUpstream("s1", page)).toBeNull();
@@ -97,10 +103,12 @@ describe("completeUpstream", () => {
97
103
  it("completes the sign-in this tab started", async () => {
98
104
  const calls = api();
99
105
  const page = browser();
100
- await startUpstream("p1", RD, calls, page);
106
+ await startUpstream(P1, RD, calls, page);
101
107
  const done = await completeUpstream("c1", "s1", calls, page);
108
+ // The provider the return completes at is the one the start kept.
109
+ expect(calls.complete.mock.calls[0][0].toString()).toBe(P1.toString());
102
110
  expect(calls.complete).toHaveBeenCalledWith(
103
- "p1",
111
+ expect.any(UpstreamProviderId),
104
112
  "c1",
105
113
  "s1",
106
114
  `${ORIGIN}${UPSTREAM_CALLBACK_PATH}`,
@@ -116,7 +124,7 @@ describe("completeUpstream", () => {
116
124
  expect(await completeUpstream("c1", "s1", calls, page)).toEqual({
117
125
  outcome: "unknown",
118
126
  });
119
- await startUpstream("p1", RD, calls, page);
127
+ await startUpstream(P1, RD, calls, page);
120
128
  await completeUpstream("c1", "s1", calls, page);
121
129
  expect(await completeUpstream("c1", "s1", calls, page)).toEqual({
122
130
  outcome: "unknown",
@@ -128,7 +136,7 @@ describe("completeUpstream", () => {
128
136
  it("reports an account that needs linking", async () => {
129
137
  const calls = api("linking-required");
130
138
  const page = browser();
131
- await startUpstream("p1", RD, calls, page);
139
+ await startUpstream(P1, RD, calls, page);
132
140
  expect(await completeUpstream("c1", "s1", calls, page)).toEqual({
133
141
  outcome: "linking-required",
134
142
  rd: RD,
@@ -147,6 +155,7 @@ async function callbackAt(
147
155
  routes: [
148
156
  { path: UPSTREAM_CALLBACK_PATH, component: { template: "<div />" } },
149
157
  { path: "/account", component: { template: "<div />" } },
158
+ { path: SECOND_FACTOR_PATH, component: { template: "<div />" } },
150
159
  ],
151
160
  });
152
161
  await router.push(at);
@@ -179,7 +188,7 @@ describe("useUpstreamCallback", () => {
179
188
  it("continues the request kept for the state", async () => {
180
189
  const calls = api();
181
190
  const page = browser();
182
- await startUpstream("p1", RD, calls, page);
191
+ await startUpstream(P1, RD, calls, page);
183
192
  const { result, left } = await callbackAt(
184
193
  `${UPSTREAM_CALLBACK_PATH}?code=c1&state=s1`,
185
194
  calls,
@@ -195,7 +204,7 @@ describe("useUpstreamCallback", () => {
195
204
  it("continues no request named only by the callback's own query", async () => {
196
205
  const calls = api();
197
206
  const page = browser();
198
- await startUpstream("p1", null, calls, page);
207
+ await startUpstream(P1, null, calls, page);
199
208
  const { result, router, left } = await callbackAt(
200
209
  `${UPSTREAM_CALLBACK_PATH}?code=c1&state=s1&rd=${encodeURIComponent(RD)}`,
201
210
  calls,
@@ -211,7 +220,7 @@ describe("useUpstreamCallback", () => {
211
220
  it("answers a provider error with the waiting request", async () => {
212
221
  const calls = api();
213
222
  const page = browser();
214
- await startUpstream("p1", RD, calls, page);
223
+ await startUpstream(P1, RD, calls, page);
215
224
  const { result } = await callbackAt(
216
225
  `${UPSTREAM_CALLBACK_PATH}?error=access_denied&state=s1`,
217
226
  calls,
@@ -230,7 +239,7 @@ describe("useUpstreamCallback", () => {
230
239
  const calls = api();
231
240
  calls.complete.mockRejectedValue(new Error("unavailable"));
232
241
  const page = browser();
233
- await startUpstream("p1", RD, calls, page);
242
+ await startUpstream(P1, RD, calls, page);
234
243
  const { result, left } = await callbackAt(
235
244
  `${UPSTREAM_CALLBACK_PATH}?code=c1&state=s1`,
236
245
  calls,
@@ -257,11 +266,35 @@ describe("useUpstreamCallback", () => {
257
266
  expect(left).toEqual([]);
258
267
  });
259
268
 
269
+ // Regression: a provider sign-in of an account owing its second factor
270
+ // was reported signed in and continued to the request. It goes to the
271
+ // second-factor page with the request it waits for.
272
+ it("continues a sign-in owing its second factor to the factor's page", async () => {
273
+ const calls = api("second-factor");
274
+ holdPendingSignIn({
275
+ secondFactors: [StepUpMethod.TOTP],
276
+ enrollableFactors: [],
277
+ browserCsrf: "csrf-1",
278
+ });
279
+ const page = browser();
280
+ await startUpstream(P1, RD, calls, page);
281
+ const { result, router, left } = await callbackAt(
282
+ `${UPSTREAM_CALLBACK_PATH}?code=c1&state=s1`,
283
+ calls,
284
+ page,
285
+ );
286
+ await result.complete();
287
+ expect(result.failure.value).toBeNull();
288
+ expect(left).toEqual([]);
289
+ expect(router.currentRoute.value.path).toBe(SECOND_FACTOR_PATH);
290
+ expect(router.currentRoute.value.query.rd).toBe(RD);
291
+ });
292
+
260
293
  // An account that needs linking is reported with its waiting request.
261
294
  it("reports linking with the waiting request", async () => {
262
295
  const calls = api("linking-required");
263
296
  const page = browser();
264
- await startUpstream("p1", RD, calls, page);
297
+ await startUpstream(P1, RD, calls, page);
265
298
  const { result, left } = await callbackAt(
266
299
  `${UPSTREAM_CALLBACK_PATH}?code=c1&state=s1`,
267
300
  calls,
@@ -3,8 +3,9 @@
3
3
  * the sign-in surface's transport. See `../upstream` for what is kept
4
4
  * between leaving for the provider and its return.
5
5
  */
6
- import { ref } from "vue";
6
+ import { ref, shallowRef } from "vue";
7
7
  import { useRoute, useRouter } from "vue-router";
8
+ import { UpstreamProviderId, required } from "@structured-id/ids";
8
9
  import { getTransport } from "../../index";
9
10
  import type { EnabledProviderInfo } from "@structured-id/proto/sid/v1/federation/upstream";
10
11
  import { UpstreamServiceClient } from "@structured-id/proto/sid/v1/federation/upstream.client";
@@ -15,51 +16,70 @@ import {
15
16
  upstreamCallbackUri,
16
17
  } from "../upstream";
17
18
  import { useLoginSuccess, type LoginSuccessOptions } from "./useLoginSuccess";
19
+ import { holdPendingSignIn, pendingSignIn } from "../pendingSignIn";
18
20
 
19
21
  export type { EnabledProviderInfo };
20
22
 
23
+ /** A provider the issuer trusts, named by its typed id. */
24
+ export type UpstreamProvider = Omit<EnabledProviderInfo, "id"> & {
25
+ id: UpstreamProviderId;
26
+ };
27
+
21
28
  /** The calls a sign-in makes; the issuer's upstream service by default. */
22
29
  export interface UpstreamApi {
23
- list(): Promise<EnabledProviderInfo[]>;
30
+ list(): Promise<UpstreamProvider[]>;
24
31
  initiate(
25
- provider: string,
32
+ provider: UpstreamProviderId,
26
33
  redirectUri: string,
27
34
  ): Promise<{ authorizationUrl: string; stateToken: string }>;
28
35
  complete(
29
- provider: string,
36
+ provider: UpstreamProviderId,
30
37
  code: string,
31
38
  state: string,
32
39
  redirectUri: string,
33
- ): Promise<"signed-in" | "linking-required">;
40
+ ): Promise<UpstreamOutcome>;
34
41
  }
35
42
 
43
+ /**
44
+ * What the issuer answered a completed return: signed in, signed in but
45
+ * owing a second factor (kept as the tab's pending sign-in), or no Profile
46
+ * linked to the upstream account.
47
+ */
48
+ export type UpstreamOutcome =
49
+ "signed-in" | "second-factor" | "linking-required";
50
+
36
51
  /** The issuer's upstream service on the sign-in surface. */
37
52
  export function upstreamApi(): UpstreamApi {
38
53
  const client = () => new UpstreamServiceClient(getTransport());
39
54
  return {
40
55
  async list() {
41
56
  const { response } = await client().listEnabledProviders({});
42
- return [...response.providers].sort(
43
- (a, b) => a.displayOrder - b.displayOrder,
44
- );
57
+ return response.providers
58
+ .map((provider) => ({
59
+ ...provider,
60
+ id: required(UpstreamProviderId, provider.id),
61
+ }))
62
+ .sort((a, b) => a.displayOrder - b.displayOrder);
45
63
  },
46
64
  async initiate(provider, redirectUri) {
47
65
  const { response } = await client().initiateUpstreamAuth({
48
- providerId: provider,
66
+ providerId: provider.toWire(),
49
67
  redirectUri,
50
68
  });
51
69
  return response;
52
70
  },
53
71
  async complete(provider, code, state, redirectUri) {
54
72
  const { response } = await client().handleUpstreamCallback({
55
- providerId: provider,
73
+ providerId: provider.toWire(),
56
74
  code,
57
75
  state,
58
76
  redirectUri,
59
77
  });
60
78
  switch (response.result.oneofKind) {
61
79
  case "success":
62
- return "signed-in";
80
+ return holdPendingSignIn(response.result.success)
81
+ ? "second-factor"
82
+ : "signed-in";
63
83
  case "linkingRequired":
64
84
  return "linking-required";
65
85
  default:
@@ -95,7 +115,7 @@ export function pageBrowser(
95
115
  * starts the sign-in for this origin's callback page.
96
116
  */
97
117
  export async function startUpstream(
98
- provider: string,
118
+ provider: UpstreamProviderId,
99
119
  rd: string | null,
100
120
  api: UpstreamApi = upstreamApi(),
101
121
  browser: UpstreamBrowser = pageBrowser(),
@@ -124,7 +144,7 @@ export function abandonUpstream(
124
144
  export type UpstreamReturn =
125
145
  | {
126
146
  /** `failed`: the issuer could not complete it. */
127
- outcome: "signed-in" | "linking-required" | "failed";
147
+ outcome: UpstreamOutcome | "failed";
128
148
  rd: string | null;
129
149
  }
130
150
  | { outcome: "unknown" };
@@ -170,7 +190,10 @@ export function useUpstreamSignIn(
170
190
  rd: () => string | null,
171
191
  options: UpstreamSignInOptions = {},
172
192
  ) {
173
- const providers = ref<EnabledProviderInfo[]>([]);
193
+ // Shallow: a typed id keeps its bytes in private fields, which a deep
194
+ // reactive proxy cannot read.
195
+ const providers = shallowRef<UpstreamProvider[]>([]);
196
+ /** The text form of the provider the sign-in is leaving for. */
174
197
  const loading = ref<string | null>(null);
175
198
  const error = ref<string | null>(null);
176
199
  const api = () => options.api ?? upstreamApi();
@@ -184,8 +207,8 @@ export function useUpstreamSignIn(
184
207
  }
185
208
  }
186
209
 
187
- async function signInWith(provider: string) {
188
- loading.value = provider;
210
+ async function signInWith(provider: UpstreamProviderId) {
211
+ loading.value = provider.toString();
189
212
  error.value = null;
190
213
  try {
191
214
  await startUpstream(
@@ -262,7 +285,7 @@ export function useUpstreamCallback(
262
285
  return;
263
286
  }
264
287
  continued.value = done.rd;
265
- if (done.outcome !== "signed-in") {
288
+ if (done.outcome === "linking-required" || done.outcome === "failed") {
266
289
  failure.value = done.outcome;
267
290
  return;
268
291
  }
@@ -270,7 +293,10 @@ export function useUpstreamCallback(
270
293
  path: route.path,
271
294
  query: done.rd ? { rd: done.rd } : {},
272
295
  });
273
- await onLoginSuccess();
296
+ // A sign-in owing its second factor continues to the factor's page.
297
+ await onLoginSuccess(
298
+ done.outcome === "second-factor" ? { pending: pendingSignIn() } : {},
299
+ );
274
300
  } catch {
275
301
  failure.value = "failed";
276
302
  }
@@ -17,5 +17,8 @@ export * from "./components";
17
17
  // Upstream identity provider sign-in: what a return keeps
18
18
  export * from "./upstream";
19
19
 
20
+ // A browser sign-in owing its second factor
21
+ export * from "./pendingSignIn";
22
+
20
23
  // Auth helper
21
24
  export { authMeta } from "./auth";
@@ -0,0 +1,126 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { StepUpMethod } from "@structured-id/proto/sid/v1/authn/auth";
3
+ import {
4
+ endPendingSignIn,
5
+ holdPendingSignIn,
6
+ pendingSignIn,
7
+ pendingSignInMeta,
8
+ } from "./pendingSignIn";
9
+
10
+ const owing = {
11
+ secondFactors: [StepUpMethod.TOTP],
12
+ enrollableFactors: [],
13
+ browserCsrf: "csrf-1",
14
+ };
15
+
16
+ beforeEach(() => {
17
+ endPendingSignIn();
18
+ sessionStorage.clear();
19
+ });
20
+
21
+ describe("pending sign-in", () => {
22
+ // A sign-in owing its second factor is kept with the CSRF token its
23
+ // completion sends; the token is not part of what pages read.
24
+ it("keeps a sign-in that owes a factor", () => {
25
+ expect(holdPendingSignIn(owing)).toEqual({
26
+ secondFactors: [StepUpMethod.TOTP],
27
+ enrollableFactors: [],
28
+ });
29
+ expect(pendingSignIn()).toEqual({
30
+ secondFactors: [StepUpMethod.TOTP],
31
+ enrollableFactors: [],
32
+ });
33
+ expect(pendingSignInMeta()).toEqual({ "x-csrf-token": "csrf-1" });
34
+ });
35
+
36
+ // An account that must enroll its first factor is pending too.
37
+ it("keeps a sign-in that must enroll its first factor", () => {
38
+ holdPendingSignIn({
39
+ secondFactors: [],
40
+ enrollableFactors: [StepUpMethod.TOTP],
41
+ browserCsrf: "csrf-2",
42
+ });
43
+ expect(pendingSignIn()?.enrollableFactors).toEqual([StepUpMethod.TOTP]);
44
+ expect(pendingSignInMeta()).toEqual({ "x-csrf-token": "csrf-2" });
45
+ });
46
+
47
+ // A complete sign-in replaces an earlier pending one: the earlier token
48
+ // is never sent with the new session.
49
+ it("forgets an earlier pending sign-in when a sign-in completes", () => {
50
+ holdPendingSignIn(owing);
51
+ expect(
52
+ holdPendingSignIn({ secondFactors: [], enrollableFactors: [] }),
53
+ ).toBeNull();
54
+ expect(pendingSignIn()).toBeNull();
55
+ expect(pendingSignInMeta()).toEqual({});
56
+ });
57
+
58
+ // Without its CSRF token the browser cannot complete the context; the
59
+ // answer is refused loudly rather than kept unusable.
60
+ it("refuses a pending answer without its CSRF token", () => {
61
+ expect(() =>
62
+ holdPendingSignIn({ ...owing, browserCsrf: undefined }),
63
+ ).toThrow();
64
+ expect(pendingSignIn()).toBeNull();
65
+ });
66
+
67
+ it("ends the pending sign-in", () => {
68
+ holdPendingSignIn(owing);
69
+ endPendingSignIn();
70
+ expect(pendingSignIn()).toBeNull();
71
+ expect(pendingSignInMeta()).toEqual({});
72
+ });
73
+
74
+ // Regression: with tab storage denied by browser policy, reading
75
+ // `sessionStorage` threw, so even a complete sign-in failed. A complete
76
+ // sign-in needs no storage, and a pending one is still held for the
77
+ // completion this page load sends.
78
+ it("works without tab storage", () => {
79
+ const denied = Object.getOwnPropertyDescriptor(window, "sessionStorage");
80
+ Object.defineProperty(window, "sessionStorage", {
81
+ configurable: true,
82
+ get() {
83
+ throw new DOMException("denied", "SecurityError");
84
+ },
85
+ });
86
+ try {
87
+ expect(
88
+ holdPendingSignIn({ secondFactors: [], enrollableFactors: [] }),
89
+ ).toBeNull();
90
+ holdPendingSignIn(owing);
91
+ expect(pendingSignIn()?.secondFactors).toEqual([StepUpMethod.TOTP]);
92
+ expect(pendingSignInMeta()).toEqual({ "x-csrf-token": "csrf-1" });
93
+ endPendingSignIn();
94
+ expect(pendingSignIn()).toBeNull();
95
+ } finally {
96
+ Object.defineProperty(window, "sessionStorage", denied!);
97
+ }
98
+ });
99
+
100
+ // A reload completes the sign-in from the tab's copy of the record.
101
+ it("is completed after a reload from the tab's copy", async () => {
102
+ holdPendingSignIn(owing);
103
+ const reloaded = await freshLoad();
104
+ expect(reloaded.pendingSignIn()?.secondFactors).toEqual([
105
+ StepUpMethod.TOTP,
106
+ ]);
107
+ expect(reloaded.pendingSignInMeta()).toEqual({ "x-csrf-token": "csrf-1" });
108
+ });
109
+
110
+ // A copy the reloaded page cannot read is no pending sign-in.
111
+ it("reads a corrupt copy as none", async () => {
112
+ sessionStorage.setItem("sid.pending-sign-in", "{not json");
113
+ expect((await freshLoad()).pendingSignIn()).toBeNull();
114
+ sessionStorage.setItem(
115
+ "sid.pending-sign-in",
116
+ JSON.stringify({ csrf: 1, secondFactors: [], enrollableFactors: [] }),
117
+ );
118
+ expect((await freshLoad()).pendingSignIn()).toBeNull();
119
+ });
120
+ });
121
+
122
+ /** The module as a new page load finds it: nothing held, only the tab's copy. */
123
+ async function freshLoad(): Promise<typeof import("./pendingSignIn")> {
124
+ vi.resetModules();
125
+ return import("./pendingSignIn");
126
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * A browser sign-in that still owes its second factor. The first factor's
3
+ * answer names the factors that complete it, the factors it may enroll
4
+ * first, and the CSRF token the browser sends with its pending cookie to
5
+ * complete it. The record is kept until the completion ends it: a completion
6
+ * whose outcome is unknown is repeated with the same token and receives the
7
+ * sign-in it delivered.
8
+ *
9
+ * The page holds the record itself; the tab's session storage keeps a copy
10
+ * so a reload can still complete it. Storage the browser denies only loses
11
+ * that copy: a complete sign-in never needs it.
12
+ */
13
+ import { StepUpMethod } from "@structured-id/proto/sid/v1/authn/auth";
14
+
15
+ /** What a first-factor answer states about the sign-in it delivered. */
16
+ export interface FirstFactorAnswer {
17
+ secondFactors: StepUpMethod[];
18
+ enrollableFactors: StepUpMethod[];
19
+ browserCsrf?: string;
20
+ }
21
+
22
+ /** A sign-in owing its second factor. */
23
+ export interface PendingSignIn {
24
+ /** The enrolled factors that complete it; empty when it must enroll one first. */
25
+ secondFactors: StepUpMethod[];
26
+ /** The factors it may enroll first, when the account has none. */
27
+ enrollableFactors: StepUpMethod[];
28
+ }
29
+
30
+ interface Kept extends PendingSignIn {
31
+ csrf: string;
32
+ }
33
+
34
+ const KEY = "sid.pending-sign-in";
35
+
36
+ /** The CSRF header the sign-in surface checks with the pending cookie. */
37
+ const CSRF_HEADER = "x-csrf-token";
38
+
39
+ /** The record this page load holds; `undefined` until it read the tab's copy. */
40
+ let held: Kept | null | undefined;
41
+
42
+ /** The tab's session storage, or null where the browser denies it. */
43
+ function tabStorage(): Storage | null {
44
+ try {
45
+ return sessionStorage;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function methods(value: unknown): StepUpMethod[] | null {
52
+ return Array.isArray(value) && value.every((m) => typeof m === "number")
53
+ ? (value as StepUpMethod[])
54
+ : null;
55
+ }
56
+
57
+ /** The tab's copy, none when it is absent, unreadable or not this module's. */
58
+ function stored(): Kept | null {
59
+ try {
60
+ const raw = tabStorage()?.getItem(KEY);
61
+ if (!raw) return null;
62
+ const kept = JSON.parse(raw) as Record<string, unknown>;
63
+ const secondFactors = methods(kept.secondFactors);
64
+ const enrollableFactors = methods(kept.enrollableFactors);
65
+ if (typeof kept.csrf !== "string" || !secondFactors || !enrollableFactors) {
66
+ return null;
67
+ }
68
+ return { csrf: kept.csrf, secondFactors, enrollableFactors };
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /** Keep `kept` as the record, its copy in the tab when the tab allows. */
75
+ function keep(kept: Kept | null): void {
76
+ held = kept;
77
+ try {
78
+ const storage = tabStorage();
79
+ if (kept) storage?.setItem(KEY, JSON.stringify(kept));
80
+ else storage?.removeItem(KEY);
81
+ } catch {
82
+ // Without the copy a reload no longer completes the sign-in; this page
83
+ // load still does.
84
+ }
85
+ }
86
+
87
+ function current(): Kept | null {
88
+ if (held === undefined) held = stored();
89
+ return held;
90
+ }
91
+
92
+ /**
93
+ * Keep the sign-in `answer` delivered when it still owes a factor; null when
94
+ * the sign-in is complete. A new first factor replaces any earlier record.
95
+ */
96
+ export function holdPendingSignIn(
97
+ answer: FirstFactorAnswer,
98
+ ): PendingSignIn | null {
99
+ keep(null);
100
+ const owed =
101
+ answer.secondFactors.length > 0 || answer.enrollableFactors.length > 0;
102
+ if (!owed) return null;
103
+ if (!answer.browserCsrf) {
104
+ throw new Error(
105
+ "The issuer answered a pending sign-in without its CSRF token",
106
+ );
107
+ }
108
+ const kept: Kept = {
109
+ csrf: answer.browserCsrf,
110
+ secondFactors: [...answer.secondFactors],
111
+ enrollableFactors: [...answer.enrollableFactors],
112
+ };
113
+ keep(kept);
114
+ return {
115
+ secondFactors: kept.secondFactors,
116
+ enrollableFactors: kept.enrollableFactors,
117
+ };
118
+ }
119
+
120
+ /** The sign-in this tab is completing, if any. */
121
+ export function pendingSignIn(): PendingSignIn | null {
122
+ const kept = current();
123
+ return kept
124
+ ? {
125
+ secondFactors: kept.secondFactors,
126
+ enrollableFactors: kept.enrollableFactors,
127
+ }
128
+ : null;
129
+ }
130
+
131
+ /** The metadata a completion of the pending sign-in sends; empty without one. */
132
+ export function pendingSignInMeta(): Record<string, string> {
133
+ const kept = current();
134
+ return kept ? { [CSRF_HEADER]: kept.csrf } : {};
135
+ }
136
+
137
+ /** End the pending sign-in: its completion delivered the session, or it ended. */
138
+ export function endPendingSignIn(): void {
139
+ keep(null);
140
+ }
@@ -1,4 +1,5 @@
1
1
  import { beforeEach, describe, expect, it } from "vitest";
2
+ import { UpstreamProviderId } from "@structured-id/ids";
2
3
  import {
3
4
  UPSTREAM_CALLBACK_PATH,
4
5
  peekUpstream,
@@ -8,6 +9,7 @@ import {
8
9
  upstreamRequestOfPage,
9
10
  } from "./upstream";
10
11
 
12
+ const P1 = UpstreamProviderId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d20");
11
13
  const RD =
12
14
  "https://sid.example.com/i/0123456789abcdef0123456789abcdef/oauth2/authorize?client_id=ops&request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Ar";
13
15
 
@@ -35,25 +37,26 @@ describe("the kept return", () => {
35
37
  // The return to the callback page finds the provider and the request it
36
38
  // started with, under the provider's state.
37
39
  it("is found under its state", () => {
38
- rememberUpstream(sessionStorage, "s1", { provider: "p1", rd: RD });
39
- expect(peekUpstream(sessionStorage, "s1")).toEqual({
40
- provider: "p1",
41
- rd: RD,
42
- });
40
+ rememberUpstream(sessionStorage, "s1", { provider: P1, rd: RD });
41
+ const kept = peekUpstream(sessionStorage, "s1");
42
+ expect(kept?.provider.toString()).toBe(P1.toString());
43
+ expect(kept?.rd).toBe(RD);
43
44
  expect(peekUpstream(sessionStorage, "s2")).toBeUndefined();
44
45
  });
45
46
 
46
47
  // A sign-in without an authorization request returns without one.
47
48
  it("keeps the absence of a request", () => {
48
- rememberUpstream(sessionStorage, "s1", { provider: "p1", rd: null });
49
+ rememberUpstream(sessionStorage, "s1", { provider: P1, rd: null });
49
50
  expect(peekUpstream(sessionStorage, "s1")?.rd).toBeNull();
50
51
  });
51
52
 
52
53
  // A return completes once: taking it removes it, so a reloaded or replayed
53
54
  // callback finds nothing to continue.
54
55
  it("is taken once", () => {
55
- rememberUpstream(sessionStorage, "s1", { provider: "p1", rd: RD });
56
- expect(takeUpstream(sessionStorage, "s1")?.provider).toBe("p1");
56
+ rememberUpstream(sessionStorage, "s1", { provider: P1, rd: RD });
57
+ expect(takeUpstream(sessionStorage, "s1")?.provider.toString()).toBe(
58
+ P1.toString(),
59
+ );
57
60
  expect(takeUpstream(sessionStorage, "s1")).toBeUndefined();
58
61
  });
59
62
 
@@ -61,8 +64,13 @@ describe("the kept return", () => {
61
64
  it("ignores what it did not write", () => {
62
65
  sessionStorage.setItem("sid.upstream.s1", "not json");
63
66
  sessionStorage.setItem("sid.upstream.s2", JSON.stringify({ rd: RD }));
67
+ sessionStorage.setItem(
68
+ "sid.upstream.s3",
69
+ JSON.stringify({ provider: "not-an-id", rd: RD }),
70
+ );
64
71
  expect(peekUpstream(sessionStorage, "s1")).toBeUndefined();
65
72
  expect(peekUpstream(sessionStorage, "s2")).toBeUndefined();
73
+ expect(peekUpstream(sessionStorage, "s3")).toBeUndefined();
66
74
  });
67
75
  });
68
76
 
@@ -84,7 +92,7 @@ describe("upstreamRequestOfPage", () => {
84
92
  // provider's return does not carry; the entry stays for the page to take.
85
93
  // An rd in the callback's own query is never used.
86
94
  it("reads the callback's request only from its kept return", () => {
87
- rememberUpstream(sessionStorage, "s1", { provider: "p1", rd: RD });
95
+ rememberUpstream(sessionStorage, "s1", { provider: P1, rd: RD });
88
96
  const callback = location(`${UPSTREAM_CALLBACK_PATH}?code=c&state=s1`);
89
97
  expect(upstreamRequestOfPage(callback, sessionStorage)).toBe(RD);
90
98
  expect(peekUpstream(sessionStorage, "s1")).toBeDefined();
@@ -106,7 +114,7 @@ describe("upstreamRequestOfPage", () => {
106
114
 
107
115
  // An app with its own callback path reads its own callback page.
108
116
  it("follows the app's callback path", () => {
109
- rememberUpstream(sessionStorage, "s1", { provider: "p1", rd: RD });
117
+ rememberUpstream(sessionStorage, "s1", { provider: P1, rd: RD });
110
118
  expect(
111
119
  upstreamRequestOfPage(
112
120
  location("/upstream/return?state=s1"),
@@ -9,6 +9,7 @@
9
9
  * The kept entry is no credential: the issuer keeps the request's state,
10
10
  * PKCE verifier and nonce itself and checks `state` on completion.
11
11
  */
12
+ import { UpstreamProviderId } from "@structured-id/ids";
12
13
 
13
14
  /** Where the provider returns the browser, unless the app names its own. */
14
15
  export const UPSTREAM_CALLBACK_PATH = "/account/upstream/callback";
@@ -27,20 +28,23 @@ export function upstreamCallbackUri(
27
28
  /** What the return to the callback page needs. */
28
29
  export interface PendingUpstream {
29
30
  /** The provider the sign-in started at. */
30
- provider: string;
31
+ provider: UpstreamProviderId;
31
32
  /** The authorization request the sign-in completes, if one sent the user. */
32
33
  rd: string | null;
33
34
  }
34
35
 
35
36
  const PREFIX = "sid.upstream.";
36
37
 
37
- /** Keep `pending` for the return carrying `state`. */
38
+ /** Keep `pending` for the return carrying `state`; the provider by its text form. */
38
39
  export function rememberUpstream(
39
40
  storage: Storage,
40
41
  state: string,
41
42
  pending: PendingUpstream,
42
43
  ): void {
43
- storage.setItem(PREFIX + state, JSON.stringify(pending));
44
+ storage.setItem(
45
+ PREFIX + state,
46
+ JSON.stringify({ provider: pending.provider.toString(), rd: pending.rd }),
47
+ );
44
48
  }
45
49
 
46
50
  /** What was kept for `state`, left in place; `undefined` when nothing is. */
@@ -51,12 +55,11 @@ export function peekUpstream(
51
55
  const kept = storage.getItem(PREFIX + state);
52
56
  if (kept === null) return undefined;
53
57
  try {
54
- const parsed = JSON.parse(kept) as Partial<PendingUpstream>;
55
- if (typeof parsed.provider !== "string" || !parsed.provider) {
56
- return undefined;
57
- }
58
+ const parsed = JSON.parse(kept) as { provider?: unknown; rd?: unknown };
59
+ if (typeof parsed.provider !== "string") return undefined;
58
60
  return {
59
- provider: parsed.provider,
61
+ // An entry whose provider is no identifier was not kept by this code.
62
+ provider: UpstreamProviderId.parse(parsed.provider),
60
63
  rd: typeof parsed.rd === "string" ? parsed.rd : null,
61
64
  };
62
65
  } catch {