@zoreal/oauth2-react 0.2.8 → 0.2.9

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/README.md CHANGED
@@ -239,6 +239,14 @@ What the modal does:
239
239
  | **Language** | Ships its own copy in 39 locales (listed below). Pass `locale` and the modal matches the page it opened on; omit it and it follows the browser's preference list, English as the floor. Arabic, Hebrew and Urdu flip the dialog to RTL. |
240
240
  | **Accessibility** | `role="dialog"`, `aria-modal`, labelled by its title, focus moved in on open, scroll locked, visible focus rings, and a `prefers-reduced-motion` fallback. |
241
241
 
242
+ The code on screen is not static. A QR sign-in shows one frame of a sequence
243
+ the provider rotates every few seconds, and it refuses a frame the sequence has
244
+ already moved past. That is what stops a code being relayed: a screenshot of a
245
+ sign-in code, sent to someone else to scan and approve, is spent before it
246
+ arrives, so the picture buys an attacker nothing. The SDK re-fetches the frame
247
+ on the provider's cadence and the modal loads the next one before it swaps, so
248
+ the code never blinks out while a camera is aimed at it.
249
+
242
250
  ### Languages
243
251
 
244
252
  Arabic · Bengali · Bosnian · Bulgarian · Chinese (Simplified) · Chinese (Traditional) ·
@@ -266,9 +274,12 @@ If you already have a design system you want the QR to live inside, opt out:
266
274
  ```
267
275
 
268
276
  `onPairingStateChange` then carries everything you need on every state:
269
- `qrUrl`, `pairUrl`, `status`, `expiresIn` and `cancel`. Render `qrUrl` in an
270
- `<img>`; do not draw your own. `PairingModal` is also exported if you want the
271
- real dialog but on your own terms.
277
+ `qrUrl`, `pairUrl`, `status`, `expiresIn`, `qrRefreshSeconds` and `cancel`.
278
+ Render `qrUrl` in an `<img>`; do not draw your own. Render it **on every state
279
+ you are given**, never the first one you saw: `qrUrl` is a moving frame, the
280
+ SDK hands you a new one every few seconds, and a UI that caches the first
281
+ value shows a code that stops working. `PairingModal` is also exported if you
282
+ want the real dialog but on your own terms.
272
283
 
273
284
  ## Scopes and claims
274
285
 
package/dist/index.cjs CHANGED
@@ -39,10 +39,11 @@ var import_react2 = require("react");
39
39
 
40
40
  // src/wire.ts
41
41
  var WIRE_VERSION = 1;
42
- var SDK_VERSION = "0.2.8";
42
+ var SDK_VERSION = "0.2.9";
43
43
  var DEFAULT_ISSUER = "https://id.zoreal.com";
44
44
  var POLL_INTERVAL_MS = 2e3;
45
45
  var POLL_INTERVAL_ENROLLING_MS = 5e3;
46
+ var DEFAULT_QR_REFRESH_SECONDS = 3;
46
47
 
47
48
  // src/PairingModal.tsx
48
49
  var import_react = require("react");
@@ -1147,6 +1148,23 @@ function PairingModal({
1147
1148
  const deadlineRef = (0, import_react.useRef)(0);
1148
1149
  const [remaining, setRemaining] = (0, import_react.useState)(Math.round(timeoutMs / 1e3));
1149
1150
  const settled = state.status === "claimed" || state.status === "enrolling";
1151
+ const [frameUrl, setFrameUrl] = (0, import_react.useState)(qrUrl);
1152
+ (0, import_react.useEffect)(() => {
1153
+ if (settled || frameUrl === qrUrl) return;
1154
+ let abandoned = false;
1155
+ const next = new Image();
1156
+ next.onload = () => {
1157
+ if (!abandoned) setFrameUrl(qrUrl);
1158
+ };
1159
+ next.onerror = () => {
1160
+ };
1161
+ next.src = qrUrl;
1162
+ return () => {
1163
+ abandoned = true;
1164
+ next.onload = null;
1165
+ next.onerror = null;
1166
+ };
1167
+ }, [qrUrl, settled, frameUrl]);
1150
1168
  const onCancelRef = (0, import_react.useRef)(onCancel);
1151
1169
  onCancelRef.current = onCancel;
1152
1170
  (0, import_react.useEffect)(() => {
@@ -1198,7 +1216,7 @@ function PairingModal({
1198
1216
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h2", { id: titleId, className: cx("title"), children: settled ? t.titleApprove : t.title }),
1199
1217
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("body-text"), children: body }),
1200
1218
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: cx("qr-well"), children: [
1201
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("img", { className: cx("qr"), "data-spent": settled, src: qrUrl, alt: t.qrAlt, width: 180, height: 180 }),
1219
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("img", { className: cx("qr"), "data-spent": settled, src: frameUrl, alt: t.qrAlt, width: 180, height: 180 }),
1202
1220
  settled && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: cx("qr-overlay"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: cx("qr-badge"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(IconPhone, {}) }) })
1203
1221
  ] }),
1204
1222
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: cx("status"), children: [
@@ -1336,55 +1354,99 @@ async function startPairing(issuer, params) {
1336
1354
  }
1337
1355
  return body;
1338
1356
  }
1357
+ function qrRefreshSecondsOf(started) {
1358
+ const seconds = started.qr_refresh_seconds;
1359
+ return typeof seconds === "number" && seconds > 0 ? seconds : DEFAULT_QR_REFRESH_SECONDS;
1360
+ }
1361
+ function qrFrameUrl(issuer, requestId) {
1362
+ return `${issuer}/pair/${encodeURIComponent(requestId)}/qr.svg?t=${Date.now()}`;
1363
+ }
1339
1364
  var sleep = (ms, signal) => new Promise((resolve, reject) => {
1340
1365
  if (signal?.aborted) {
1341
1366
  reject(new DOMException("aborted", "AbortError"));
1342
1367
  return;
1343
1368
  }
1344
- const t = setTimeout(resolve, ms);
1345
- signal?.addEventListener("abort", () => {
1346
- clearTimeout(t);
1369
+ const onAbort = () => {
1370
+ clearTimeout(timer);
1347
1371
  reject(new DOMException("aborted", "AbortError"));
1348
- });
1372
+ };
1373
+ const timer = setTimeout(() => {
1374
+ signal?.removeEventListener("abort", onAbort);
1375
+ resolve();
1376
+ }, ms);
1377
+ signal?.addEventListener("abort", onAbort, { once: true });
1349
1378
  });
1350
- async function pollUntilApproved(issuer, requestId, onState, signal) {
1351
- for (; ; ) {
1352
- const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
1353
- signal
1354
- });
1355
- const body = await parseJson(response);
1356
- if (!response.ok) {
1357
- throw new OAuthFlowError(
1358
- body.error ?? "server_error",
1359
- body.error_description ?? `Pairing status failed (${response.status})`
1360
- );
1361
- }
1362
- onState?.({
1363
- status: body.status,
1364
- expiresIn: body.expires_in,
1365
- enrolmentDeadline: body.enrolment_deadline
1379
+ async function pollUntilApproved(issuer, requestId, onState, signal, options = {}) {
1380
+ let last = { status: "pending" };
1381
+ const emit = (state) => {
1382
+ last = state;
1383
+ onState?.(state);
1384
+ };
1385
+ let frames = null;
1386
+ const stopFrames = () => {
1387
+ frames?.abort();
1388
+ frames = null;
1389
+ };
1390
+ const startFrames = () => {
1391
+ const seconds = options.qrRefreshSeconds;
1392
+ if (frames || signal?.aborted || typeof seconds !== "number" || !(seconds > 0)) return;
1393
+ const period = seconds * 1e3;
1394
+ const controller = new AbortController();
1395
+ frames = controller;
1396
+ signal?.addEventListener("abort", stopFrames, { signal: controller.signal });
1397
+ void (async () => {
1398
+ let due = Date.now() + period;
1399
+ for (; ; ) {
1400
+ await sleep(Math.max(0, due - Date.now()), controller.signal);
1401
+ emit({ ...last, qrUrl: qrFrameUrl(issuer, requestId) });
1402
+ due = Date.now() + period;
1403
+ }
1404
+ })().catch(() => {
1366
1405
  });
1367
- switch (body.status) {
1368
- case "approved":
1369
- if (!body.code) {
1370
- throw new OAuthFlowError("server_error", "approved with no authorization code");
1371
- }
1372
- return body.code;
1373
- case "denied":
1374
- throw new FlowAbandonedError({ type: "request_denied", description: body.error_description });
1375
- case "expired":
1376
- throw new FlowAbandonedError({ type: "request_expired", description: body.error_description });
1377
- case "cancelled":
1378
- throw new FlowAbandonedError({
1379
- type: "request_expired",
1380
- description: body.error_description ?? "the provider cancelled the pairing request"
1381
- });
1382
- case "enrolling":
1383
- await sleep(POLL_INTERVAL_ENROLLING_MS, signal);
1384
- break;
1385
- default:
1386
- await sleep(POLL_INTERVAL_MS, signal);
1406
+ };
1407
+ try {
1408
+ for (; ; ) {
1409
+ const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
1410
+ signal
1411
+ });
1412
+ const body = await parseJson(response);
1413
+ if (!response.ok) {
1414
+ throw new OAuthFlowError(
1415
+ body.error ?? "server_error",
1416
+ body.error_description ?? `Pairing status failed (${response.status})`
1417
+ );
1418
+ }
1419
+ emit({
1420
+ status: body.status,
1421
+ expiresIn: body.expires_in,
1422
+ enrolmentDeadline: body.enrolment_deadline
1423
+ });
1424
+ if (body.status === "pending") startFrames();
1425
+ else stopFrames();
1426
+ switch (body.status) {
1427
+ case "approved":
1428
+ if (!body.code) {
1429
+ throw new OAuthFlowError("server_error", "approved with no authorization code");
1430
+ }
1431
+ return body.code;
1432
+ case "denied":
1433
+ throw new FlowAbandonedError({ type: "request_denied", description: body.error_description });
1434
+ case "expired":
1435
+ throw new FlowAbandonedError({ type: "request_expired", description: body.error_description });
1436
+ case "cancelled":
1437
+ throw new FlowAbandonedError({
1438
+ type: "request_expired",
1439
+ description: body.error_description ?? "the provider cancelled the pairing request"
1440
+ });
1441
+ case "enrolling":
1442
+ await sleep(POLL_INTERVAL_ENROLLING_MS, signal);
1443
+ break;
1444
+ default:
1445
+ await sleep(POLL_INTERVAL_MS, signal);
1446
+ }
1387
1447
  }
1448
+ } finally {
1449
+ stopFrames();
1388
1450
  }
1389
1451
  }
1390
1452
  async function exchangeCode(issuer, input) {
@@ -1411,6 +1473,11 @@ function isMobileUserAgent() {
1411
1473
  if (typeof navigator === "undefined") return false;
1412
1474
  return /android|iphone|ipad|ipod/i.test(navigator.userAgent);
1413
1475
  }
1476
+ function resolveDisplay(display) {
1477
+ if (display === "link") return "link";
1478
+ if (display === "qr") return "qr";
1479
+ return isMobileUserAgent() ? "link" : "qr";
1480
+ }
1414
1481
 
1415
1482
  // src/pkce.ts
1416
1483
  var VERIFIER_BYTES = 32;
@@ -1457,6 +1524,8 @@ function useZorealFlow(options) {
1457
1524
  const verifier = generateVerifier();
1458
1525
  const state = generateState();
1459
1526
  const nonce = generateState();
1527
+ const display = resolveDisplay(opts.display);
1528
+ const useAppLink = display === "link";
1460
1529
  try {
1461
1530
  const started = await startPairing(issuer, {
1462
1531
  client_id: clientId,
@@ -1468,7 +1537,8 @@ function useZorealFlow(options) {
1468
1537
  acr_values: Array.isArray(opts.acr_values) ? opts.acr_values.join(" ") : opts.acr_values,
1469
1538
  max_age: opts.max_age,
1470
1539
  prompt: opts.prompt,
1471
- locale
1540
+ locale,
1541
+ display
1472
1542
  });
1473
1543
  let code;
1474
1544
  let selectBy = "device";
@@ -1476,8 +1546,8 @@ function useZorealFlow(options) {
1476
1546
  code = started.code;
1477
1547
  selectBy = "session";
1478
1548
  } else {
1479
- const useAppLink = opts.display === "link" || opts.display !== "qr" && isMobileUserAgent();
1480
1549
  selectBy = useAppLink ? "app_link" : "qr";
1550
+ const qrRefreshSeconds = qrRefreshSecondsOf(started);
1481
1551
  const cancel = () => {
1482
1552
  controller.abort();
1483
1553
  setPairing(null);
@@ -1485,21 +1555,23 @@ function useZorealFlow(options) {
1485
1555
  };
1486
1556
  const surface = {
1487
1557
  pairUrl: started.pair_url,
1488
- qrUrl: `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`,
1489
1558
  appLink: useAppLink,
1490
- cancel
1559
+ cancel,
1560
+ // The app link has no QR and therefore no cadence to report.
1561
+ ...useAppLink ? null : { qrRefreshSeconds }
1491
1562
  };
1563
+ let qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;
1492
1564
  const active = {
1493
1565
  requestId: started.request_id,
1494
1566
  pairUrl: surface.pairUrl,
1495
- qrUrl: surface.qrUrl,
1496
- state: { status: "pending", expiresIn: started.expires_in, ...surface },
1567
+ qrUrl,
1568
+ state: { status: "pending", expiresIn: started.expires_in, qrUrl, ...surface },
1497
1569
  appLink: useAppLink,
1498
1570
  cancel
1499
1571
  };
1500
1572
  setPairing(active);
1501
1573
  if (!useAppLink) {
1502
- publishRef.current?.({ state: active.state, qrUrl: surface.qrUrl, cancel });
1574
+ publishRef.current?.({ state: active.state, qrUrl, cancel });
1503
1575
  }
1504
1576
  opts.onPairingStateChange?.(active.state);
1505
1577
  if (useAppLink) {
@@ -1509,16 +1581,20 @@ function useZorealFlow(options) {
1509
1581
  issuer,
1510
1582
  started.request_id,
1511
1583
  (s) => {
1512
- const enriched = { ...s, ...surface };
1584
+ if (s.qrUrl) qrUrl = s.qrUrl;
1585
+ const enriched = { ...s, ...surface, qrUrl };
1513
1586
  setPairing(
1514
- (p) => p && p.requestId === started.request_id ? { ...p, state: enriched } : p
1587
+ (p) => p && p.requestId === started.request_id ? { ...p, qrUrl, state: enriched } : p
1515
1588
  );
1516
1589
  if (!useAppLink) {
1517
- publishRef.current?.({ state: enriched, qrUrl: surface.qrUrl, cancel });
1590
+ publishRef.current?.({ state: enriched, qrUrl, cancel });
1518
1591
  }
1519
1592
  opts.onPairingStateChange?.(enriched);
1520
1593
  },
1521
- controller.signal
1594
+ controller.signal,
1595
+ // The app link has no QR to animate, and the provider answers 404
1596
+ // for one, so the frames are asked for only on the QR surface.
1597
+ { qrRefreshSeconds: useAppLink ? void 0 : qrRefreshSeconds }
1522
1598
  );
1523
1599
  }
1524
1600
  setPairing(null);