@structured-id/ui-core 4.15.1 → 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.1",
3
+ "version": "4.15.2",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
package/src/index.ts CHANGED
@@ -80,6 +80,7 @@ export {
80
80
  rpcRefusal,
81
81
  rpcReason,
82
82
  refusalMessage,
83
+ MFA_CONTINUATION_TEXTS,
83
84
  REFUSAL_TEXTS,
84
85
  SID_ERROR_DOMAIN,
85
86
  SignInRefusedError,
@@ -10,6 +10,7 @@ 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,
@@ -195,6 +196,42 @@ describe("rpcRefusal", () => {
195
196
  expect(rpcRefusal(refused([info]))?.fieldViolations).toEqual([]);
196
197
  });
197
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
+
198
235
  it("survives an unreadable trailer", () => {
199
236
  const err = new RpcError("m", "INTERNAL", {
200
237
  "grpc-status-details-bin": "!!!",
@@ -231,6 +231,17 @@ 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
+
234
245
  /**
235
246
  * A password sign-in refused on this device: it did not verify (RFC 9807
236
247
  * §6.4). It reads as the server's own refusal of a sign-in, so the text does
@@ -259,6 +270,14 @@ export function refusalMessage(
259
270
  const refusal = rpcRefusal(err);
260
271
  if (refusal) {
261
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
+ }
262
281
  if (
263
282
  refusal.reasonCode !== null &&
264
283
  refusal.reasonCode !== ErrorReason.ERROR_REASON_UNSPECIFIED