@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structured-id/ui-core",
3
- "version": "4.15.0",
3
+ "version": "4.15.2",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
package/src/index.ts CHANGED
@@ -80,8 +80,10 @@ export {
80
80
  rpcRefusal,
81
81
  rpcReason,
82
82
  refusalMessage,
83
+ MFA_CONTINUATION_TEXTS,
83
84
  REFUSAL_TEXTS,
84
85
  SID_ERROR_DOMAIN,
86
+ SignInRefusedError,
85
87
  type FieldViolation,
86
88
  type RpcRefusal,
87
89
  } from "./utils/rpcRefusal";
@@ -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
- it("propagates a wrong password (the client cannot finish)", async () => {
751
- vi.mocked(client.loginFinish).mockRejectedValue(
752
- new Error("login finish: InvalidLoginError"),
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 expect(login("alice@test.com", "wrong")).rejects.toThrow(
756
- "InvalidLoginError",
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
- const finalization = await c.loginFinish(
537
- password,
538
- start.state,
539
- started.credentialResponse,
540
- new Uint8Array(),
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": "!!!",
@@ -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