@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 +3 -3
- package/src/index.ts +1 -0
- package/src/profile/components/SidLoginForm.vue +3 -6
- package/src/profile/composables/useAuth.test.ts +39 -7
- package/src/profile/composables/useAuth.ts +19 -6
- package/src/profile/composables/useAuthApi.ids.test.ts +2 -0
- package/src/profile/composables/useAuthApi.opaque.test.ts +7 -1
- package/src/profile/composables/useAuthApi.pending.test.ts +134 -0
- package/src/profile/composables/useAuthApi.ts +81 -24
- package/src/profile/composables/useLoginSuccess.test.ts +28 -1
- package/src/profile/composables/useLoginSuccess.ts +22 -1
- package/src/profile/composables/useUpstreamSignIn.test.ts +54 -21
- package/src/profile/composables/useUpstreamSignIn.ts +44 -18
- package/src/profile/index.ts +3 -0
- package/src/profile/pendingSignIn.test.ts +126 -0
- package/src/profile/pendingSignIn.ts +140 -0
- package/src/profile/upstream.test.ts +18 -10
- package/src/profile/upstream.ts +11 -8
- package/src/utils/rpcRefusal.test.ts +11 -0
- package/src/utils/rpcRefusal.ts +17 -1
|
@@ -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:
|
|
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(() =>
|
|
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(
|
|
63
|
+
await startUpstream(P1, RD, calls, page);
|
|
55
64
|
expect(calls.initiate).toHaveBeenCalledWith(
|
|
56
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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<
|
|
30
|
+
list(): Promise<UpstreamProvider[]>;
|
|
24
31
|
initiate(
|
|
25
|
-
provider:
|
|
32
|
+
provider: UpstreamProviderId,
|
|
26
33
|
redirectUri: string,
|
|
27
34
|
): Promise<{ authorizationUrl: string; stateToken: string }>;
|
|
28
35
|
complete(
|
|
29
|
-
provider:
|
|
36
|
+
provider: UpstreamProviderId,
|
|
30
37
|
code: string,
|
|
31
38
|
state: string,
|
|
32
39
|
redirectUri: string,
|
|
33
|
-
): Promise<
|
|
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
|
|
43
|
-
(
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/profile/index.ts
CHANGED
|
@@ -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
|
+
}
|