@structured-id/ui-core 4.15.0 → 4.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -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", () => ({
|
|
@@ -747,14 +748,26 @@ describe("createAuth login", () => {
|
|
|
747
748
|
expect(result.pending).toEqual(pending);
|
|
748
749
|
});
|
|
749
750
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
);
|
|
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);
|
|
754
757
|
const { login } = createAuth(loader);
|
|
755
|
-
await
|
|
756
|
-
|
|
757
|
-
);
|
|
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);
|
|
758
771
|
expect(api.loginFinish).not.toHaveBeenCalled();
|
|
759
772
|
});
|
|
760
773
|
});
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { CredentialId, SessionId } from "@structured-id/ids";
|
|
18
18
|
import type { PendingSignIn } from "../pendingSignIn";
|
|
19
|
+
import { SignInRefusedError } from "../../utils/rpcRefusal";
|
|
19
20
|
import {
|
|
20
21
|
completePasswordReset,
|
|
21
22
|
evaluatePasswordHistory,
|
|
@@ -533,12 +534,20 @@ export function createAuth(loadClient: ZkppClientLoader) {
|
|
|
533
534
|
const started = await opaqueLoginStart(identifier, start.request);
|
|
534
535
|
onStep?.(3);
|
|
535
536
|
// An ordinary sign-in runs under the empty context.
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
+
}
|
|
542
551
|
const finished = await opaqueLoginFinish(
|
|
543
552
|
identifier,
|
|
544
553
|
finalization,
|
|
@@ -10,10 +10,12 @@ import {
|
|
|
10
10
|
} from "@structured-id/proto/google/rpc/error_details";
|
|
11
11
|
import { ErrorReason } from "@structured-id/proto/sid/v1/common/errors";
|
|
12
12
|
import {
|
|
13
|
+
MFA_CONTINUATION_TEXTS,
|
|
13
14
|
REFUSAL_TEXTS,
|
|
14
15
|
refusalMessage,
|
|
15
16
|
rpcReason,
|
|
16
17
|
rpcRefusal,
|
|
18
|
+
SignInRefusedError,
|
|
17
19
|
} from "./rpcRefusal";
|
|
18
20
|
|
|
19
21
|
/** A refused call as the gRPC-Web transport raises it: the status in the trailer. */
|
|
@@ -143,6 +145,16 @@ describe("rpcRefusal", () => {
|
|
|
143
145
|
);
|
|
144
146
|
});
|
|
145
147
|
|
|
148
|
+
// A password the device refused reads as the server's refusal of a sign-in,
|
|
149
|
+
// never as the client's own wording, so a wrong password and an unknown
|
|
150
|
+
// account look alike.
|
|
151
|
+
it("shows a refused sign-in as the server's sign-in refusal", () => {
|
|
152
|
+
const refused = new Error("invalid login");
|
|
153
|
+
expect(
|
|
154
|
+
refusalMessage(new SignInRefusedError(refused), "Login failed"),
|
|
155
|
+
).toBe(REFUSAL_TEXTS[ErrorReason.AUTHENTICATION_FAILED]);
|
|
156
|
+
});
|
|
157
|
+
|
|
146
158
|
// A refusal that names the fields it concerns lets the form show each
|
|
147
159
|
// text under its field: the server's text for people when it sent one,
|
|
148
160
|
// else its description.
|
|
@@ -184,6 +196,42 @@ describe("rpcRefusal", () => {
|
|
|
184
196
|
expect(rpcRefusal(refused([info]))?.fieldViolations).toEqual([]);
|
|
185
197
|
});
|
|
186
198
|
|
|
199
|
+
// Bug prevented: every MFA_REQUIRED read "Set up a second factor first.",
|
|
200
|
+
// so a session that was only too old was told to set up a factor it may
|
|
201
|
+
// already hold, and that setup was refused the same way. The text follows
|
|
202
|
+
// what the server asks for in `continuation`.
|
|
203
|
+
it("says what an MFA_REQUIRED refusal asks for", () => {
|
|
204
|
+
const mfa = (continuation?: string) =>
|
|
205
|
+
refused(
|
|
206
|
+
[
|
|
207
|
+
Any.pack(
|
|
208
|
+
ErrorInfo.create({
|
|
209
|
+
reason: "MFA_REQUIRED",
|
|
210
|
+
domain: "structured.id",
|
|
211
|
+
metadata: continuation ? { continuation } : {},
|
|
212
|
+
}),
|
|
213
|
+
ErrorInfo,
|
|
214
|
+
),
|
|
215
|
+
],
|
|
216
|
+
"a%20stronger%20authentication%20is%20required",
|
|
217
|
+
);
|
|
218
|
+
expect(refusalMessage(mfa("reauthenticate"))).toBe(
|
|
219
|
+
MFA_CONTINUATION_TEXTS.reauthenticate,
|
|
220
|
+
);
|
|
221
|
+
expect(refusalMessage(mfa("step_up"))).toBe(MFA_CONTINUATION_TEXTS.step_up);
|
|
222
|
+
expect(refusalMessage(mfa("authenticate"))).toBe(
|
|
223
|
+
MFA_CONTINUATION_TEXTS.authenticate,
|
|
224
|
+
);
|
|
225
|
+
expect(refusalMessage(mfa())).toBe(REFUSAL_TEXTS[ErrorReason.MFA_REQUIRED]);
|
|
226
|
+
// A continuation this client does not know keeps the reason's text.
|
|
227
|
+
expect(refusalMessage(mfa("later"))).toBe(
|
|
228
|
+
REFUSAL_TEXTS[ErrorReason.MFA_REQUIRED],
|
|
229
|
+
);
|
|
230
|
+
expect(MFA_CONTINUATION_TEXTS.reauthenticate).not.toBe(
|
|
231
|
+
REFUSAL_TEXTS[ErrorReason.MFA_REQUIRED],
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
187
235
|
it("survives an unreadable trailer", () => {
|
|
188
236
|
const err = new RpcError("m", "INTERNAL", {
|
|
189
237
|
"grpc-status-details-bin": "!!!",
|
package/src/utils/rpcRefusal.ts
CHANGED
|
@@ -231,18 +231,53 @@ export const REFUSAL_TEXTS: Readonly<
|
|
|
231
231
|
[ErrorReason.INTERNAL_ERROR]: "Something went wrong on the server.",
|
|
232
232
|
};
|
|
233
233
|
|
|
234
|
+
/**
|
|
235
|
+
* What an `MFA_REQUIRED` refusal asks of the user, by the `continuation` its
|
|
236
|
+
* `ErrorInfo` names. Without one (no second factor enrolled) the reason's own
|
|
237
|
+
* text stands.
|
|
238
|
+
*/
|
|
239
|
+
export const MFA_CONTINUATION_TEXTS = {
|
|
240
|
+
authenticate: "Sign in to your account first.",
|
|
241
|
+
step_up: "Confirm it's you with your second factor to continue.",
|
|
242
|
+
reauthenticate: "Sign in again to make this change.",
|
|
243
|
+
} as const;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* A password sign-in refused on this device: it did not verify (RFC 9807
|
|
247
|
+
* §6.4). It reads as the server's own refusal of a sign-in, so the text does
|
|
248
|
+
* not tell a wrong password from an unknown account.
|
|
249
|
+
*/
|
|
250
|
+
export class SignInRefusedError extends Error {
|
|
251
|
+
constructor(cause: unknown) {
|
|
252
|
+
super("The sign-in did not verify", { cause });
|
|
253
|
+
this.name = "SignInRefusedError";
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
234
257
|
/**
|
|
235
258
|
* The text to show for a failed call: the server's text for people, else the
|
|
236
259
|
* client's text for SID's reason, else `fallback`, the caller's context. A
|
|
237
|
-
* client-side `Error` (no call refused) keeps its own message
|
|
260
|
+
* client-side `Error` (no call refused) keeps its own message, except a
|
|
261
|
+
* refused sign-in, which reads as the server's refusal of one.
|
|
238
262
|
*/
|
|
239
263
|
export function refusalMessage(
|
|
240
264
|
err: unknown,
|
|
241
265
|
fallback = "Request failed",
|
|
242
266
|
): string {
|
|
267
|
+
if (err instanceof SignInRefusedError) {
|
|
268
|
+
return REFUSAL_TEXTS[ErrorReason.AUTHENTICATION_FAILED];
|
|
269
|
+
}
|
|
243
270
|
const refusal = rpcRefusal(err);
|
|
244
271
|
if (refusal) {
|
|
245
272
|
if (refusal.localized) return refusal.localized;
|
|
273
|
+
if (refusal.reasonCode === ErrorReason.MFA_REQUIRED) {
|
|
274
|
+
const asked = refusal.metadata["continuation"];
|
|
275
|
+
if (asked && Object.hasOwn(MFA_CONTINUATION_TEXTS, asked)) {
|
|
276
|
+
return MFA_CONTINUATION_TEXTS[
|
|
277
|
+
asked as keyof typeof MFA_CONTINUATION_TEXTS
|
|
278
|
+
];
|
|
279
|
+
}
|
|
280
|
+
}
|
|
246
281
|
if (
|
|
247
282
|
refusal.reasonCode !== null &&
|
|
248
283
|
refusal.reasonCode !== ErrorReason.ERROR_REASON_UNSPECIFIED
|