@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 +3 -3
- package/src/profile/components/SidLoginForm.vue +3 -6
- package/src/profile/composables/useAuth.test.ts +19 -0
- package/src/profile/composables/useAuth.ts +4 -0
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@structured-id/ui-core",
|
|
3
|
-
"version": "4.
|
|
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.
|
|
46
|
-
"@structured-id/proto": "^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 {
|
|
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
|
|
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: {
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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 {
|
|
126
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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
|
-
/**
|
|
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 {
|
|
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 {
|
|
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(
|
|
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:
|
|
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
|
+
}
|
|
@@ -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:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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:
|
|
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:
|
|
56
|
-
expect(takeUpstream(sessionStorage, "s1")?.provider).toBe(
|
|
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:
|
|
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:
|
|
117
|
+
rememberUpstream(sessionStorage, "s1", { provider: P1, rd: RD });
|
|
110
118
|
expect(
|
|
111
119
|
upstreamRequestOfPage(
|
|
112
120
|
location("/upstream/return?state=s1"),
|
package/src/profile/upstream.ts
CHANGED
|
@@ -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:
|
|
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(
|
|
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
|
|
55
|
-
if (typeof parsed.provider !== "string"
|
|
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
|
|
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 {
|