@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/dist/index.d.cts CHANGED
@@ -37,8 +37,25 @@ interface PairingState {
37
37
  * carrying nothing to render. Present on every callback of a QR/link flow.
38
38
  */
39
39
  pairUrl?: string;
40
- /** The provider-served SVG of pairUrl. Put it in an <img>; do not draw your own. */
40
+ /**
41
+ * The provider-served SVG to show right now. Put it in an <img>; do not draw
42
+ * your own.
43
+ *
44
+ * IT CHANGES DURING THE PAIRING. The code on screen is one frame of a
45
+ * rotating sequence, and a frame the provider has moved past is refused when
46
+ * the phone tries to claim it, which is what stops a screenshot of the code
47
+ * from being relayed to someone else's browser. So render the qrUrl of the
48
+ * state you are handed, every time you are handed one; a UI that caches the
49
+ * first value shows a code that stops working seconds later.
50
+ */
41
51
  qrUrl?: string;
52
+ /**
53
+ * How often, in seconds, `qrUrl` is replaced while the pairing is pending.
54
+ * Informational: the SDK already emits a new state on that cadence, so a
55
+ * custom UI does not need a timer of its own. Absent on the app link, which
56
+ * has no QR.
57
+ */
58
+ qrRefreshSeconds?: number;
42
59
  /** True when the flow resolved to the app link (mobile) rather than a QR. */
43
60
  appLink?: boolean;
44
61
  /** Abandons this pairing: stops the poll. Wire it to your UI's cancel control. */
package/dist/index.d.ts CHANGED
@@ -37,8 +37,25 @@ interface PairingState {
37
37
  * carrying nothing to render. Present on every callback of a QR/link flow.
38
38
  */
39
39
  pairUrl?: string;
40
- /** The provider-served SVG of pairUrl. Put it in an <img>; do not draw your own. */
40
+ /**
41
+ * The provider-served SVG to show right now. Put it in an <img>; do not draw
42
+ * your own.
43
+ *
44
+ * IT CHANGES DURING THE PAIRING. The code on screen is one frame of a
45
+ * rotating sequence, and a frame the provider has moved past is refused when
46
+ * the phone tries to claim it, which is what stops a screenshot of the code
47
+ * from being relayed to someone else's browser. So render the qrUrl of the
48
+ * state you are handed, every time you are handed one; a UI that caches the
49
+ * first value shows a code that stops working seconds later.
50
+ */
41
51
  qrUrl?: string;
52
+ /**
53
+ * How often, in seconds, `qrUrl` is replaced while the pairing is pending.
54
+ * Informational: the SDK already emits a new state on that cadence, so a
55
+ * custom UI does not need a timer of its own. Absent on the app link, which
56
+ * has no QR.
57
+ */
58
+ qrRefreshSeconds?: number;
42
59
  /** True when the flow resolved to the app link (mobile) rather than a QR. */
43
60
  appLink?: boolean;
44
61
  /** Abandons this pairing: stops the poll. Wire it to your UI's cancel control. */
package/dist/index.js CHANGED
@@ -5,10 +5,11 @@ import { createContext, useContext, useMemo, useState as useState2 } from "react
5
5
 
6
6
  // src/wire.ts
7
7
  var WIRE_VERSION = 1;
8
- var SDK_VERSION = "0.2.8";
8
+ var SDK_VERSION = "0.2.9";
9
9
  var DEFAULT_ISSUER = "https://id.zoreal.com";
10
10
  var POLL_INTERVAL_MS = 2e3;
11
11
  var POLL_INTERVAL_ENROLLING_MS = 5e3;
12
+ var DEFAULT_QR_REFRESH_SECONDS = 3;
12
13
 
13
14
  // src/PairingModal.tsx
14
15
  import { useEffect, useId, useRef, useState } from "react";
@@ -1113,6 +1114,23 @@ function PairingModal({
1113
1114
  const deadlineRef = useRef(0);
1114
1115
  const [remaining, setRemaining] = useState(Math.round(timeoutMs / 1e3));
1115
1116
  const settled = state.status === "claimed" || state.status === "enrolling";
1117
+ const [frameUrl, setFrameUrl] = useState(qrUrl);
1118
+ useEffect(() => {
1119
+ if (settled || frameUrl === qrUrl) return;
1120
+ let abandoned = false;
1121
+ const next = new Image();
1122
+ next.onload = () => {
1123
+ if (!abandoned) setFrameUrl(qrUrl);
1124
+ };
1125
+ next.onerror = () => {
1126
+ };
1127
+ next.src = qrUrl;
1128
+ return () => {
1129
+ abandoned = true;
1130
+ next.onload = null;
1131
+ next.onerror = null;
1132
+ };
1133
+ }, [qrUrl, settled, frameUrl]);
1116
1134
  const onCancelRef = useRef(onCancel);
1117
1135
  onCancelRef.current = onCancel;
1118
1136
  useEffect(() => {
@@ -1164,7 +1182,7 @@ function PairingModal({
1164
1182
  /* @__PURE__ */ jsx3("h2", { id: titleId, className: cx("title"), children: settled ? t.titleApprove : t.title }),
1165
1183
  /* @__PURE__ */ jsx3("p", { className: cx("body-text"), children: body }),
1166
1184
  /* @__PURE__ */ jsxs3("div", { className: cx("qr-well"), children: [
1167
- /* @__PURE__ */ jsx3("img", { className: cx("qr"), "data-spent": settled, src: qrUrl, alt: t.qrAlt, width: 180, height: 180 }),
1185
+ /* @__PURE__ */ jsx3("img", { className: cx("qr"), "data-spent": settled, src: frameUrl, alt: t.qrAlt, width: 180, height: 180 }),
1168
1186
  settled && /* @__PURE__ */ jsx3("span", { className: cx("qr-overlay"), children: /* @__PURE__ */ jsx3("span", { className: cx("qr-badge"), children: /* @__PURE__ */ jsx3(IconPhone, {}) }) })
1169
1187
  ] }),
1170
1188
  /* @__PURE__ */ jsxs3("div", { className: cx("status"), children: [
@@ -1302,55 +1320,99 @@ async function startPairing(issuer, params) {
1302
1320
  }
1303
1321
  return body;
1304
1322
  }
1323
+ function qrRefreshSecondsOf(started) {
1324
+ const seconds = started.qr_refresh_seconds;
1325
+ return typeof seconds === "number" && seconds > 0 ? seconds : DEFAULT_QR_REFRESH_SECONDS;
1326
+ }
1327
+ function qrFrameUrl(issuer, requestId) {
1328
+ return `${issuer}/pair/${encodeURIComponent(requestId)}/qr.svg?t=${Date.now()}`;
1329
+ }
1305
1330
  var sleep = (ms, signal) => new Promise((resolve, reject) => {
1306
1331
  if (signal?.aborted) {
1307
1332
  reject(new DOMException("aborted", "AbortError"));
1308
1333
  return;
1309
1334
  }
1310
- const t = setTimeout(resolve, ms);
1311
- signal?.addEventListener("abort", () => {
1312
- clearTimeout(t);
1335
+ const onAbort = () => {
1336
+ clearTimeout(timer);
1313
1337
  reject(new DOMException("aborted", "AbortError"));
1314
- });
1338
+ };
1339
+ const timer = setTimeout(() => {
1340
+ signal?.removeEventListener("abort", onAbort);
1341
+ resolve();
1342
+ }, ms);
1343
+ signal?.addEventListener("abort", onAbort, { once: true });
1315
1344
  });
1316
- async function pollUntilApproved(issuer, requestId, onState, signal) {
1317
- for (; ; ) {
1318
- const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
1319
- signal
1320
- });
1321
- const body = await parseJson(response);
1322
- if (!response.ok) {
1323
- throw new OAuthFlowError(
1324
- body.error ?? "server_error",
1325
- body.error_description ?? `Pairing status failed (${response.status})`
1326
- );
1327
- }
1328
- onState?.({
1329
- status: body.status,
1330
- expiresIn: body.expires_in,
1331
- enrolmentDeadline: body.enrolment_deadline
1345
+ async function pollUntilApproved(issuer, requestId, onState, signal, options = {}) {
1346
+ let last = { status: "pending" };
1347
+ const emit = (state) => {
1348
+ last = state;
1349
+ onState?.(state);
1350
+ };
1351
+ let frames = null;
1352
+ const stopFrames = () => {
1353
+ frames?.abort();
1354
+ frames = null;
1355
+ };
1356
+ const startFrames = () => {
1357
+ const seconds = options.qrRefreshSeconds;
1358
+ if (frames || signal?.aborted || typeof seconds !== "number" || !(seconds > 0)) return;
1359
+ const period = seconds * 1e3;
1360
+ const controller = new AbortController();
1361
+ frames = controller;
1362
+ signal?.addEventListener("abort", stopFrames, { signal: controller.signal });
1363
+ void (async () => {
1364
+ let due = Date.now() + period;
1365
+ for (; ; ) {
1366
+ await sleep(Math.max(0, due - Date.now()), controller.signal);
1367
+ emit({ ...last, qrUrl: qrFrameUrl(issuer, requestId) });
1368
+ due = Date.now() + period;
1369
+ }
1370
+ })().catch(() => {
1332
1371
  });
1333
- switch (body.status) {
1334
- case "approved":
1335
- if (!body.code) {
1336
- throw new OAuthFlowError("server_error", "approved with no authorization code");
1337
- }
1338
- return body.code;
1339
- case "denied":
1340
- throw new FlowAbandonedError({ type: "request_denied", description: body.error_description });
1341
- case "expired":
1342
- throw new FlowAbandonedError({ type: "request_expired", description: body.error_description });
1343
- case "cancelled":
1344
- throw new FlowAbandonedError({
1345
- type: "request_expired",
1346
- description: body.error_description ?? "the provider cancelled the pairing request"
1347
- });
1348
- case "enrolling":
1349
- await sleep(POLL_INTERVAL_ENROLLING_MS, signal);
1350
- break;
1351
- default:
1352
- await sleep(POLL_INTERVAL_MS, signal);
1372
+ };
1373
+ try {
1374
+ for (; ; ) {
1375
+ const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
1376
+ signal
1377
+ });
1378
+ const body = await parseJson(response);
1379
+ if (!response.ok) {
1380
+ throw new OAuthFlowError(
1381
+ body.error ?? "server_error",
1382
+ body.error_description ?? `Pairing status failed (${response.status})`
1383
+ );
1384
+ }
1385
+ emit({
1386
+ status: body.status,
1387
+ expiresIn: body.expires_in,
1388
+ enrolmentDeadline: body.enrolment_deadline
1389
+ });
1390
+ if (body.status === "pending") startFrames();
1391
+ else stopFrames();
1392
+ switch (body.status) {
1393
+ case "approved":
1394
+ if (!body.code) {
1395
+ throw new OAuthFlowError("server_error", "approved with no authorization code");
1396
+ }
1397
+ return body.code;
1398
+ case "denied":
1399
+ throw new FlowAbandonedError({ type: "request_denied", description: body.error_description });
1400
+ case "expired":
1401
+ throw new FlowAbandonedError({ type: "request_expired", description: body.error_description });
1402
+ case "cancelled":
1403
+ throw new FlowAbandonedError({
1404
+ type: "request_expired",
1405
+ description: body.error_description ?? "the provider cancelled the pairing request"
1406
+ });
1407
+ case "enrolling":
1408
+ await sleep(POLL_INTERVAL_ENROLLING_MS, signal);
1409
+ break;
1410
+ default:
1411
+ await sleep(POLL_INTERVAL_MS, signal);
1412
+ }
1353
1413
  }
1414
+ } finally {
1415
+ stopFrames();
1354
1416
  }
1355
1417
  }
1356
1418
  async function exchangeCode(issuer, input) {
@@ -1377,6 +1439,11 @@ function isMobileUserAgent() {
1377
1439
  if (typeof navigator === "undefined") return false;
1378
1440
  return /android|iphone|ipad|ipod/i.test(navigator.userAgent);
1379
1441
  }
1442
+ function resolveDisplay(display) {
1443
+ if (display === "link") return "link";
1444
+ if (display === "qr") return "qr";
1445
+ return isMobileUserAgent() ? "link" : "qr";
1446
+ }
1380
1447
 
1381
1448
  // src/pkce.ts
1382
1449
  var VERIFIER_BYTES = 32;
@@ -1423,6 +1490,8 @@ function useZorealFlow(options) {
1423
1490
  const verifier = generateVerifier();
1424
1491
  const state = generateState();
1425
1492
  const nonce = generateState();
1493
+ const display = resolveDisplay(opts.display);
1494
+ const useAppLink = display === "link";
1426
1495
  try {
1427
1496
  const started = await startPairing(issuer, {
1428
1497
  client_id: clientId,
@@ -1434,7 +1503,8 @@ function useZorealFlow(options) {
1434
1503
  acr_values: Array.isArray(opts.acr_values) ? opts.acr_values.join(" ") : opts.acr_values,
1435
1504
  max_age: opts.max_age,
1436
1505
  prompt: opts.prompt,
1437
- locale
1506
+ locale,
1507
+ display
1438
1508
  });
1439
1509
  let code;
1440
1510
  let selectBy = "device";
@@ -1442,8 +1512,8 @@ function useZorealFlow(options) {
1442
1512
  code = started.code;
1443
1513
  selectBy = "session";
1444
1514
  } else {
1445
- const useAppLink = opts.display === "link" || opts.display !== "qr" && isMobileUserAgent();
1446
1515
  selectBy = useAppLink ? "app_link" : "qr";
1516
+ const qrRefreshSeconds = qrRefreshSecondsOf(started);
1447
1517
  const cancel = () => {
1448
1518
  controller.abort();
1449
1519
  setPairing(null);
@@ -1451,21 +1521,23 @@ function useZorealFlow(options) {
1451
1521
  };
1452
1522
  const surface = {
1453
1523
  pairUrl: started.pair_url,
1454
- qrUrl: `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`,
1455
1524
  appLink: useAppLink,
1456
- cancel
1525
+ cancel,
1526
+ // The app link has no QR and therefore no cadence to report.
1527
+ ...useAppLink ? null : { qrRefreshSeconds }
1457
1528
  };
1529
+ let qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;
1458
1530
  const active = {
1459
1531
  requestId: started.request_id,
1460
1532
  pairUrl: surface.pairUrl,
1461
- qrUrl: surface.qrUrl,
1462
- state: { status: "pending", expiresIn: started.expires_in, ...surface },
1533
+ qrUrl,
1534
+ state: { status: "pending", expiresIn: started.expires_in, qrUrl, ...surface },
1463
1535
  appLink: useAppLink,
1464
1536
  cancel
1465
1537
  };
1466
1538
  setPairing(active);
1467
1539
  if (!useAppLink) {
1468
- publishRef.current?.({ state: active.state, qrUrl: surface.qrUrl, cancel });
1540
+ publishRef.current?.({ state: active.state, qrUrl, cancel });
1469
1541
  }
1470
1542
  opts.onPairingStateChange?.(active.state);
1471
1543
  if (useAppLink) {
@@ -1475,16 +1547,20 @@ function useZorealFlow(options) {
1475
1547
  issuer,
1476
1548
  started.request_id,
1477
1549
  (s) => {
1478
- const enriched = { ...s, ...surface };
1550
+ if (s.qrUrl) qrUrl = s.qrUrl;
1551
+ const enriched = { ...s, ...surface, qrUrl };
1479
1552
  setPairing(
1480
- (p) => p && p.requestId === started.request_id ? { ...p, state: enriched } : p
1553
+ (p) => p && p.requestId === started.request_id ? { ...p, qrUrl, state: enriched } : p
1481
1554
  );
1482
1555
  if (!useAppLink) {
1483
- publishRef.current?.({ state: enriched, qrUrl: surface.qrUrl, cancel });
1556
+ publishRef.current?.({ state: enriched, qrUrl, cancel });
1484
1557
  }
1485
1558
  opts.onPairingStateChange?.(enriched);
1486
1559
  },
1487
- controller.signal
1560
+ controller.signal,
1561
+ // The app link has no QR to animate, and the provider answers 404
1562
+ // for one, so the frames are asked for only on the QR surface.
1563
+ { qrRefreshSeconds: useAppLink ? void 0 : qrRefreshSeconds }
1488
1564
  );
1489
1565
  }
1490
1566
  setPairing(null);