@structured-id/ui-core 4.15.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structured-id/ui-core",
3
- "version": "4.15.0",
3
+ "version": "4.15.1",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
package/src/index.ts CHANGED
@@ -82,6 +82,7 @@ export {
82
82
  refusalMessage,
83
83
  REFUSAL_TEXTS,
84
84
  SID_ERROR_DOMAIN,
85
+ SignInRefusedError,
85
86
  type FieldViolation,
86
87
  type RpcRefusal,
87
88
  } 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,
@@ -14,6 +14,7 @@ import {
14
14
  refusalMessage,
15
15
  rpcReason,
16
16
  rpcRefusal,
17
+ SignInRefusedError,
17
18
  } from "./rpcRefusal";
18
19
 
19
20
  /** A refused call as the gRPC-Web transport raises it: the status in the trailer. */
@@ -143,6 +144,16 @@ describe("rpcRefusal", () => {
143
144
  );
144
145
  });
145
146
 
147
+ // A password the device refused reads as the server's refusal of a sign-in,
148
+ // never as the client's own wording, so a wrong password and an unknown
149
+ // account look alike.
150
+ it("shows a refused sign-in as the server's sign-in refusal", () => {
151
+ const refused = new Error("invalid login");
152
+ expect(
153
+ refusalMessage(new SignInRefusedError(refused), "Login failed"),
154
+ ).toBe(REFUSAL_TEXTS[ErrorReason.AUTHENTICATION_FAILED]);
155
+ });
156
+
146
157
  // A refusal that names the fields it concerns lets the form show each
147
158
  // text under its field: the server's text for people when it sent one,
148
159
  // else its description.
@@ -231,15 +231,31 @@ export const REFUSAL_TEXTS: Readonly<
231
231
  [ErrorReason.INTERNAL_ERROR]: "Something went wrong on the server.",
232
232
  };
233
233
 
234
+ /**
235
+ * A password sign-in refused on this device: it did not verify (RFC 9807
236
+ * §6.4). It reads as the server's own refusal of a sign-in, so the text does
237
+ * not tell a wrong password from an unknown account.
238
+ */
239
+ export class SignInRefusedError extends Error {
240
+ constructor(cause: unknown) {
241
+ super("The sign-in did not verify", { cause });
242
+ this.name = "SignInRefusedError";
243
+ }
244
+ }
245
+
234
246
  /**
235
247
  * The text to show for a failed call: the server's text for people, else the
236
248
  * 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.
249
+ * client-side `Error` (no call refused) keeps its own message, except a
250
+ * refused sign-in, which reads as the server's refusal of one.
238
251
  */
239
252
  export function refusalMessage(
240
253
  err: unknown,
241
254
  fallback = "Request failed",
242
255
  ): string {
256
+ if (err instanceof SignInRefusedError) {
257
+ return REFUSAL_TEXTS[ErrorReason.AUTHENTICATION_FAILED];
258
+ }
243
259
  const refusal = rpcRefusal(err);
244
260
  if (refusal) {
245
261
  if (refusal.localized) return refusal.localized;