@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@structured-id/ui-core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.15.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"@protobuf-ts/grpcweb-transport": "^2.11.1",
|
|
43
43
|
"@protobuf-ts/runtime": "^2.11.1",
|
|
44
44
|
"@protobuf-ts/runtime-rpc": "^2.11.1",
|
|
45
|
-
"@structured-id/ids": "^0.
|
|
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"
|
package/src/index.ts
CHANGED
|
@@ -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
|
}>();
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type PasswordOperationProgress,
|
|
8
8
|
WrongCurrentPasswordError,
|
|
9
9
|
} from "./useAuth";
|
|
10
|
+
import { SignInRefusedError } from "../../utils/rpcRefusal";
|
|
10
11
|
|
|
11
12
|
// Mock the gRPC wrappers
|
|
12
13
|
vi.mock("./useAuthApi", () => ({
|
|
@@ -132,6 +133,7 @@ describe("createAuth prewarm", () => {
|
|
|
132
133
|
expiresIn: 60n,
|
|
133
134
|
secondFactors: [],
|
|
134
135
|
enrollableFactors: [],
|
|
136
|
+
pending: null,
|
|
135
137
|
});
|
|
136
138
|
});
|
|
137
139
|
|
|
@@ -693,6 +695,7 @@ describe("createAuth login", () => {
|
|
|
693
695
|
expiresIn: BigInt(3600),
|
|
694
696
|
secondFactors: [],
|
|
695
697
|
enrollableFactors: [],
|
|
698
|
+
pending: null,
|
|
696
699
|
});
|
|
697
700
|
});
|
|
698
701
|
|
|
@@ -725,17 +728,46 @@ describe("createAuth login", () => {
|
|
|
725
728
|
expect(result).toEqual({
|
|
726
729
|
sessionId: SESSION,
|
|
727
730
|
expiresIn: 3600,
|
|
731
|
+
pending: null,
|
|
728
732
|
});
|
|
729
733
|
});
|
|
730
734
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
+
// A sign-in owing its second factor states it, for the page to continue
|
|
736
|
+
// to the factor instead of into the app.
|
|
737
|
+
it("states the second factor the sign-in owes", async () => {
|
|
738
|
+
const pending = { secondFactors: [1], enrollableFactors: [] };
|
|
739
|
+
api.loginFinish.mockResolvedValue({
|
|
740
|
+
sessionId: SESSION,
|
|
741
|
+
expiresIn: BigInt(300),
|
|
742
|
+
secondFactors: [1],
|
|
743
|
+
enrollableFactors: [],
|
|
744
|
+
pending,
|
|
745
|
+
});
|
|
735
746
|
const { login } = createAuth(loader);
|
|
736
|
-
await
|
|
737
|
-
|
|
738
|
-
|
|
747
|
+
const result = await login("alice@test.com", "pw");
|
|
748
|
+
expect(result.pending).toEqual(pending);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
// A wrong password showed the client's own "invalid login" to the user; it
|
|
752
|
+
// is the sign-in's refusal, and nothing more is sent.
|
|
753
|
+
it("reports a wrong password as a refused sign-in", async () => {
|
|
754
|
+
const refused = new Error("invalid login");
|
|
755
|
+
refused.name = "ZkppInvalidLoginError";
|
|
756
|
+
vi.mocked(client.loginFinish).mockRejectedValue(refused);
|
|
757
|
+
const { login } = createAuth(loader);
|
|
758
|
+
const failure = await login("alice@test.com", "wrong").catch((e) => e);
|
|
759
|
+
expect(failure).toBeInstanceOf(SignInRefusedError);
|
|
760
|
+
expect((failure as SignInRefusedError).cause).toBe(refused);
|
|
761
|
+
expect(api.loginFinish).not.toHaveBeenCalled();
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
// Only the client's refusal judged the password: a malformed response or
|
|
765
|
+
// another fault comes through as itself.
|
|
766
|
+
it("reports a sign-in failure other than a refusal as itself", async () => {
|
|
767
|
+
const fault = new Error("response: invalid length");
|
|
768
|
+
vi.mocked(client.loginFinish).mockRejectedValue(fault);
|
|
769
|
+
const { login } = createAuth(loader);
|
|
770
|
+
await expect(login("alice@test.com", "pw")).rejects.toBe(fault);
|
|
739
771
|
expect(api.loginFinish).not.toHaveBeenCalled();
|
|
740
772
|
});
|
|
741
773
|
});
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* gRPC calls go through useAuthApi wrappers.
|
|
16
16
|
*/
|
|
17
17
|
import type { CredentialId, SessionId } from "@structured-id/ids";
|
|
18
|
+
import type { PendingSignIn } from "../pendingSignIn";
|
|
19
|
+
import { SignInRefusedError } from "../../utils/rpcRefusal";
|
|
18
20
|
import {
|
|
19
21
|
completePasswordReset,
|
|
20
22
|
evaluatePasswordHistory,
|
|
@@ -42,6 +44,8 @@ export interface LoginResult {
|
|
|
42
44
|
sessionId: SessionId;
|
|
43
45
|
/** Seconds until the delivered credential expires. */
|
|
44
46
|
expiresIn: number;
|
|
47
|
+
/** The second factor the sign-in still owes; null when it is complete. */
|
|
48
|
+
pending: PendingSignIn | null;
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
/**
|
|
@@ -530,12 +534,20 @@ export function createAuth(loadClient: ZkppClientLoader) {
|
|
|
530
534
|
const started = await opaqueLoginStart(identifier, start.request);
|
|
531
535
|
onStep?.(3);
|
|
532
536
|
// An ordinary sign-in runs under the empty context.
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
537
|
+
let finalization: Uint8Array;
|
|
538
|
+
try {
|
|
539
|
+
finalization = await c.loginFinish(
|
|
540
|
+
password,
|
|
541
|
+
start.state,
|
|
542
|
+
started.credentialResponse,
|
|
543
|
+
new Uint8Array(),
|
|
544
|
+
);
|
|
545
|
+
} catch (e) {
|
|
546
|
+
// The client's refusal is the sign-in's answer; any other failure (a
|
|
547
|
+
// stopped client, a malformed response) is itself.
|
|
548
|
+
if (isRefusedSignIn(e)) throw new SignInRefusedError(e);
|
|
549
|
+
throw e;
|
|
550
|
+
}
|
|
539
551
|
const finished = await opaqueLoginFinish(
|
|
540
552
|
identifier,
|
|
541
553
|
finalization,
|
|
@@ -544,6 +556,7 @@ export function createAuth(loadClient: ZkppClientLoader) {
|
|
|
544
556
|
return {
|
|
545
557
|
sessionId: finished.sessionId,
|
|
546
558
|
expiresIn: Number(finished.expiresIn),
|
|
559
|
+
pending: finished.pending,
|
|
547
560
|
};
|
|
548
561
|
}
|
|
549
562
|
|
|
@@ -143,6 +143,8 @@ describe("passkey sign-in", () => {
|
|
|
143
143
|
answers("webAuthnAuthenticationFinish", {
|
|
144
144
|
sessionId: SESSION.toWire(),
|
|
145
145
|
expiresIn: 60n,
|
|
146
|
+
secondFactors: [],
|
|
147
|
+
enrollableFactors: [],
|
|
146
148
|
});
|
|
147
149
|
const credential = new Uint8Array([1, 2]);
|
|
148
150
|
const result = await webAuthnAuthenticationFinish(credential, "a@b.c");
|
|
@@ -94,7 +94,12 @@ describe("OPAQUE gRPC wrappers", () => {
|
|
|
94
94
|
describe("opaqueLoginFinish", () => {
|
|
95
95
|
it("sends principal, finalization and the login's bytes, reads the session", async () => {
|
|
96
96
|
mockOpaqueLoginFinish.mockResolvedValue({
|
|
97
|
-
response: {
|
|
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,
|