@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.
@@ -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
+ }