@zoreal/oauth2-js 0.1.9 → 0.1.11

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
@@ -130,9 +130,17 @@ on screen, so this package does it. `startLogin` mounts a dialog, keeps it in
130
130
  step with the pairing, and takes it down when the flow settles. You render
131
131
  nothing.
132
132
 
133
+ The code on screen changes every few seconds. Each image is one frame of the
134
+ pairing, and the provider refuses a frame more than 30 seconds old, so a
135
+ screenshot of the code passed to someone else is already dead when it arrives:
136
+ signing in needs the screen as it is right now. The provider renders the
137
+ frames and this package re-fetches the image on the interval the provider
138
+ gives it, preloading the next one so the code never flickers to blank. There
139
+ is nothing to configure and nothing to draw.
140
+
133
141
  | | |
134
142
  | --- | --- |
135
- | **Mobile** | No QR. The pairing link opens the ZOREAL ID app and the modal never appears. Force one or the other with `display: 'qr'` / `'link'`. |
143
+ | **Mobile** | No QR. The pairing link opens the ZOREAL ID app and the modal never appears. Force one or the other with `display: 'qr'` / `'link'`. The choice is made before the pairing is created, because the provider binds the surface there: a link-mode pairing is claimed only by the app that opened that exact link, and has no QR at all. |
136
144
  | **Live status** | Copy and title follow the pairing: waiting for a scan, then waiting for approval once the code is claimed (the spent QR blurs out behind a phone glyph). |
137
145
  | **Countdown** | Counts down to expiry, turning amber under 20s. Reads the clock each tick rather than decrementing, so a backgrounded tab comes back honest. |
138
146
  | **Timeout** | Closes and cancels at zero. Defaults to 120s; override with `pairingTimeoutMs`. The provider's own expiry wins when it is shorter. |
@@ -184,6 +192,11 @@ startLogin({ clientId: 'ast_your_asset_id', pairingUI: 'none' });
184
192
  your own. `mountPairingModal` is also exported if you want the real dialog but
185
193
  driven on your own terms.
186
194
 
195
+ Render the `qrUrl` from the state you are given, every time, and never cache
196
+ the first one: it is a moving code, a fresh frame arrives every
197
+ `qrRefreshSeconds`, and a UI holding the first URL shows one the provider has
198
+ already refused.
199
+
187
200
  ## The handle
188
201
 
189
202
  `startLogin` returns synchronously with everything a UI needs to drive the
@@ -194,8 +207,8 @@ flow:
194
207
  | `promise` | resolves with the mode's result; rejects with `OAuthFlowError`, `FlowAbandonedError`, or an `AbortError` after `cancel()` |
195
208
  | `cancel()` | abandons the flow: stops the poll, rejects the promise |
196
209
  | `requestId` | the pairing request id, once the provider has created it |
197
- | `pairUrl` | the pairing link, once created. The same URL in QR and app link |
198
- | `qrUrl` | the provider-served SVG of `pairUrl`. Put it in an `<img>`; do not draw your own |
210
+ | `pairUrl` | the pairing link, once created. On an app-link flow it carries the start token; navigate to it verbatim |
211
+ | `qrUrl` | the provider-served SVG of the current code. Put it in an `<img>`; do not draw your own. It changes while the pairing is pending, so read it from `onState` rather than here |
199
212
  | `appLink` | true when the flow resolved to the app link (mobile) rather than a QR |
200
213
 
201
214
  `requestId`, `pairUrl`, `qrUrl` and `appLink` are `undefined` until the
@@ -203,10 +216,10 @@ pairing request exists (one round-trip), and stay `undefined` when
203
216
  `prompt: 'none'` resolves silently. The same four values also arrive on every
204
217
  `onState` callback, which is the reliable place to render from.
205
218
 
206
- `onState` receives a `PairingState` on every change:
219
+ `onState` receives a `PairingState` on every change, and on every QR frame:
207
220
  `status` (`pending | claimed | approved | denied | expired | enrolling`),
208
- `expiresIn`, `enrolmentDeadline`, `pairUrl`, `qrUrl`, `appLink`, and
209
- `cancel`.
221
+ `expiresIn`, `enrolmentDeadline`, `pairUrl`, `qrUrl`, `qrRefreshSeconds`,
222
+ `appLink`, and `cancel`.
210
223
 
211
224
  ## What resolves, per mode
212
225
 
@@ -367,6 +380,7 @@ enforced where enforcement counts: on your backend, against the verified token.
367
380
  | `isMobileUserAgent()` | whether this user agent gets the app link rather than a QR |
368
381
  | `mountPairingModal(state, { onCancel, locale?, theme?, timeoutMs? })` | mounts the dialog yourself, for `pairingUI: 'none'` callers who still want the real one. Returns `{ update, close }`, or `null` outside a browser |
369
382
  | `DEFAULT_PAIRING_TIMEOUT_MS` | `120000`, the modal's default cap |
383
+ | `DEFAULT_QR_REFRESH_SECONDS` | `3`, how often the QR frame is re-fetched when the provider does not say |
370
384
 
371
385
  Errors: `OAuthFlowError` (the provider refused; `error` is the OAuth code,
372
386
  `description` is the provider's reason verbatim) and `FlowAbandonedError` (a
@@ -383,8 +397,8 @@ is sent to the provider AND picks the modal's own language. See
383
397
  All types are exported: `PairingState`, `ZorealCredentialResponse`,
384
398
  `ZorealCodeResponse`, `StartLoginOptions`, `BrowserDirectLoginOptions`,
385
399
  `AuthCodeLoginOptions`, `LoginHandle`, `ErrorCode`, `NonOAuthError`,
386
- `SelectBy`, `AcrValue`, `PairingUI`, `ZorealTheme`, `PairingModalHandle`,
387
- `PairingModalOptions`, and the wire shapes.
400
+ `SelectBy`, `AcrValue`, `PairingUI`, `PairDisplay`, `ZorealTheme`,
401
+ `PairingModalHandle`, `PairingModalOptions`, and the wire shapes.
388
402
 
389
403
  ## Error reference
390
404
 
@@ -469,7 +483,10 @@ export function mountZorealButton(root: HTMLElement) {
469
483
  }
470
484
  if (s.qrUrl) {
471
485
  const img = document.createElement('img');
472
- img.src = s.qrUrl; // provider-served; never draw your own QR of pairUrl
486
+ // Provider-served, and a new frame every few seconds: never draw your
487
+ // own QR of pairUrl, and never keep the first URL. A real UI keeps one
488
+ // <img> and assigns src, rather than rebuilding it as this sketch does.
489
+ img.src = s.qrUrl;
473
490
  img.alt = 'Scan with the ZOREAL ID app';
474
491
  panel.append(img);
475
492
  }
@@ -609,7 +626,9 @@ And the template renders the state:
609
626
  The same shape ports to Svelte (a store fed by `onState`) or Angular (a
610
627
  service exposing an observable). The rules a wrapper must keep:
611
628
 
612
- - Render `qrUrl` in an `<img>`; never draw your own QR of `pairUrl`.
629
+ - Render `qrUrl` in an `<img>`; never draw your own QR of `pairUrl`. Re-render
630
+ it on every state: the code moves, and the last frame you were given is the
631
+ only one the provider still accepts.
613
632
  - Call `cancel()` on unmount or navigation. Do not add your own retry loop:
614
633
  the poll cadence is fixed because over-polling cancels the request
615
634
  server-side.
package/dist/index.cjs CHANGED
@@ -22,6 +22,7 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  DEFAULT_ISSUER: () => DEFAULT_ISSUER,
24
24
  DEFAULT_PAIRING_TIMEOUT_MS: () => DEFAULT_PAIRING_TIMEOUT_MS,
25
+ DEFAULT_QR_REFRESH_SECONDS: () => DEFAULT_QR_REFRESH_SECONDS,
25
26
  FlowAbandonedError: () => FlowAbandonedError,
26
27
  OAuthFlowError: () => OAuthFlowError,
27
28
  POLL_INTERVAL_ENROLLING_MS: () => POLL_INTERVAL_ENROLLING_MS,
@@ -58,11 +59,12 @@ function unsafeClaims(idToken) {
58
59
 
59
60
  // src/wire.ts
60
61
  var WIRE_VERSION = 1;
61
- var SDK_VERSION = "0.1.9";
62
+ var SDK_VERSION = "0.1.11";
62
63
  var SDK_NAME = "@zoreal/oauth2-js";
63
64
  var DEFAULT_ISSUER = "https://id.zoreal.com";
64
65
  var POLL_INTERVAL_MS = 2e3;
65
66
  var POLL_INTERVAL_ENROLLING_MS = 5e3;
67
+ var DEFAULT_QR_REFRESH_SECONDS = 3;
66
68
 
67
69
  // src/pairing.ts
68
70
  var OAuthFlowError = class extends Error {
@@ -111,11 +113,15 @@ var sleep = (ms, signal) => new Promise((resolve, reject) => {
111
113
  reject(new DOMException("aborted", "AbortError"));
112
114
  return;
113
115
  }
114
- const t = setTimeout(resolve, ms);
115
- signal?.addEventListener("abort", () => {
116
+ const onAbort = () => {
116
117
  clearTimeout(t);
117
118
  reject(new DOMException("aborted", "AbortError"));
118
- });
119
+ };
120
+ const t = setTimeout(() => {
121
+ signal?.removeEventListener("abort", onAbort);
122
+ resolve();
123
+ }, ms);
124
+ signal?.addEventListener("abort", onAbort, { once: true });
119
125
  });
120
126
  async function pollUntilApproved(issuer, requestId, onState, signal) {
121
127
  for (; ; ) {
@@ -1006,20 +1012,110 @@ var CSS = `
1006
1012
  color: var(--zrl-ink-soft);
1007
1013
  }
1008
1014
 
1015
+ /* The light around the well: a thin ring in the accent, one bright arc
1016
+ travelling round it, and a soft glow of the same colour bleeding a few
1017
+ pixels outward. It says the pairing is live without a word.
1018
+
1019
+ It sits BELOW the well, which is why the well is wrapped instead of given a
1020
+ pseudo-element. Anything drawn inside the well paints over the white quiet
1021
+ zone the camera needs, and a negative z-index from inside the well lands
1022
+ wherever the card's stacking context happens to be that frame (the card's
1023
+ entrance animation makes and unmakes one). The wrapper is the well's old
1024
+ box exactly, same size, same margin, so nothing moves; the well keeps its
1025
+ own stacking order on top, and the ring cannot touch the QR. */
1026
+ .${PREFIX}-qr-halo {
1027
+ position: relative;
1028
+ isolation: isolate;
1029
+ width: 204px;
1030
+ height: 204px;
1031
+ margin: 20px auto 0;
1032
+ }
1033
+
1009
1034
  .${PREFIX}-qr-well {
1010
1035
  position: relative;
1036
+ z-index: 1;
1011
1037
  display: grid;
1012
1038
  place-items: center;
1013
1039
  box-sizing: border-box;
1014
1040
  width: 204px;
1015
1041
  height: 204px;
1016
- margin: 20px auto 0;
1042
+ margin: 0;
1017
1043
  padding: 12px;
1018
1044
  border: 1px solid var(--zrl-line);
1019
1045
  border-radius: 16px;
1020
1046
  background: var(--zrl-qr-bg);
1021
1047
  }
1022
1048
 
1049
+ /* The glow is the ring's own alpha, blurred and drawn beneath it, so it
1050
+ follows the arc round and fades with it. It has to be a parent of the
1051
+ masked band: a filter is applied before a mask, so a shadow cast from the
1052
+ band itself would be cut off at the band's edge. Two shadows because one
1053
+ 2px source blurred over 10px is too faint to read as light; the second
1054
+ blurs the first. */
1055
+ .${PREFIX}-qr-glow {
1056
+ position: absolute;
1057
+ inset: -2px;
1058
+ pointer-events: none;
1059
+ filter: drop-shadow(0 0 3px var(--zrl-accent)) drop-shadow(0 0 10px var(--zrl-accent));
1060
+ }
1061
+
1062
+ /* The band: a 2px ring just outside the well, cut with a mask that keeps the
1063
+ padding and drops the content box. The gradient is on a pseudo-element
1064
+ rather than on the band, because in the fallback it has to rotate as a
1065
+ whole while the band stays put. The prefixed mask is for Chrome before 120;
1066
+ the unprefixed one, declared after it, wins everywhere else. */
1067
+ .${PREFIX}-qr-arc {
1068
+ position: absolute;
1069
+ inset: 0;
1070
+ padding: 2px;
1071
+ border-radius: 18px;
1072
+ overflow: hidden;
1073
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
1074
+ -webkit-mask-composite: xor;
1075
+ mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
1076
+ mask-composite: exclude;
1077
+ }
1078
+ .${PREFIX}-qr-arc::before,
1079
+ .${PREFIX}-qr-arc::after {
1080
+ content: '';
1081
+ position: absolute;
1082
+ transition: opacity 400ms cubic-bezier(0.23, 1, 0.32, 1);
1083
+ }
1084
+
1085
+ /* The light. Mostly transparent, a tail that builds over a quarter turn, a
1086
+ short full-strength head and a drop-off within a few degrees, so it reads
1087
+ as travelling rather than blinking. One revolution every 3s, linear:
1088
+ continuous motion has no ease. Oversized so its corners still cover the
1089
+ band mid-rotation in the transform fallback; the property path shrinks it
1090
+ back to the band. */
1091
+ .${PREFIX}-qr-arc::before {
1092
+ inset: -50%;
1093
+ background: conic-gradient(
1094
+ from var(--zrl-angle, 0deg),
1095
+ transparent 0deg 245deg,
1096
+ var(--zrl-accent) 332deg 346deg,
1097
+ transparent 356deg 360deg
1098
+ );
1099
+ animation: ${PREFIX}-orbit 3s linear infinite;
1100
+ }
1101
+
1102
+ /* The rest state: the same ring, evenly lit. Faint under the moving light so
1103
+ the ring reads as a ring; stronger once the light has stopped. */
1104
+ .${PREFIX}-qr-arc::after {
1105
+ inset: 0;
1106
+ background: var(--zrl-accent);
1107
+ opacity: 0.16;
1108
+ }
1109
+
1110
+ /* Spent: the light stops where it is and fades, and the ring settles to a
1111
+ dim, even glow. Paused rather than removed, so it does not jump back to
1112
+ its start on the way out. */
1113
+ .${PREFIX}-qr-halo[data-spent="true"] .${PREFIX}-qr-arc::before {
1114
+ animation-play-state: paused;
1115
+ opacity: 0;
1116
+ }
1117
+ .${PREFIX}-qr-halo[data-spent="true"] .${PREFIX}-qr-arc::after { opacity: 0.34; }
1118
+
1023
1119
  .${PREFIX}-qr {
1024
1120
  display: block;
1025
1121
  width: 100%;
@@ -1162,6 +1258,33 @@ var CSS = `
1162
1258
  70%, 100% { transform: scale(2.6); opacity: 0 }
1163
1259
  }
1164
1260
 
1261
+ /* Two ways to turn the light, one look.
1262
+ Where the browser can register a typed custom property, the gradient's own
1263
+ angle animates and the element never moves. Where it cannot (Firefox before
1264
+ 128), an untyped custom property cannot interpolate and the light would
1265
+ snap once a cycle, so the oversized pseudo-element rotates instead and the
1266
+ band's mask keeps the ring in place. At rest the two are the same pixels:
1267
+ same gradient, same centre, angle zero.
1268
+ There is no direct query for @property support. transition-behavior
1269
+ shipped after it in every engine (Chrome 117 vs 85, Firefox 129 vs 128,
1270
+ Safari 17.4 vs 16.4), so it stands in: a yes is never wrong, and a no only
1271
+ sends a capable browser down the fallback, which looks the same.
1272
+ inherits: false so the angle stays on the element that animates it and
1273
+ never reaches the host page or the well. */
1274
+ @property --zrl-angle {
1275
+ syntax: '<angle>';
1276
+ inherits: false;
1277
+ initial-value: 0deg;
1278
+ }
1279
+ @keyframes ${PREFIX}-orbit { to { transform: rotate(360deg) } }
1280
+ @keyframes ${PREFIX}-orbit-angle { to { --zrl-angle: 360deg } }
1281
+ @supports (transition-behavior: allow-discrete) {
1282
+ .${PREFIX}-qr-arc::before {
1283
+ inset: 0;
1284
+ animation-name: ${PREFIX}-orbit-angle;
1285
+ }
1286
+ }
1287
+
1165
1288
  @media (prefers-reduced-motion: reduce) {
1166
1289
  .${PREFIX}-scrim,
1167
1290
  .${PREFIX}-card,
@@ -1171,6 +1294,10 @@ var CSS = `
1171
1294
  .${PREFIX}-cancel,
1172
1295
  .${PREFIX}-close,
1173
1296
  .${PREFIX}-timer { transition: none }
1297
+ /* No travelling light. The ring keeps a still, soft glow instead, a step
1298
+ brighter than the spent state so pending and spent still read apart. */
1299
+ .${PREFIX}-qr-arc::before { animation: none; opacity: 0 }
1300
+ .${PREFIX}-qr-arc::after { opacity: 0.5 }
1174
1301
  }
1175
1302
  `;
1176
1303
  function ensureStyles() {
@@ -1275,6 +1402,10 @@ function mountPairingModal(state, options) {
1275
1402
  const title = el("h2", cx("title"));
1276
1403
  title.id = titleId;
1277
1404
  const bodyText = el("p", cx("body-text"));
1405
+ const halo = el("div", cx("qr-halo"));
1406
+ const glow = el("span", cx("qr-glow"));
1407
+ glow.setAttribute("aria-hidden", "true");
1408
+ glow.appendChild(el("span", cx("qr-arc")));
1278
1409
  const well = el("div", cx("qr-well"));
1279
1410
  const qr = el("img", cx("qr"));
1280
1411
  qr.alt = t.qrAlt;
@@ -1286,13 +1417,14 @@ function mountPairingModal(state, options) {
1286
1417
  badge.appendChild(strokeIcon(24, ["M8.5 2h7a2.5 2.5 0 0 1 2.5 2.5v15a2.5 2.5 0 0 1-2.5 2.5h-7A2.5 2.5 0 0 1 6 19.5v-15A2.5 2.5 0 0 1 8.5 2Z", "M11 18.5h2"], "1.8"));
1287
1418
  overlay.appendChild(badge);
1288
1419
  well.append(qr, overlay);
1420
+ halo.append(glow, well);
1289
1421
  const status = el("div", cx("status"));
1290
1422
  const dot = el("span", cx("dot"));
1291
1423
  dot.append(el("i"), el("i"));
1292
1424
  const statusLabel = el("span");
1293
1425
  status.append(dot, statusLabel);
1294
1426
  const timer = el("p", cx("timer"));
1295
- body.append(mark, title, bodyText, well, status, timer);
1427
+ body.append(mark, title, bodyText, halo, status, timer);
1296
1428
  const help = el("div", cx("help"));
1297
1429
  help.append(el("p", cx("help-title"), t.noIdTitle), el("p", cx("help-body"), t.noIdBody));
1298
1430
  const footer = el("div", cx("footer"));
@@ -1305,14 +1437,38 @@ function mountPairingModal(state, options) {
1305
1437
  scrim.appendChild(card);
1306
1438
  const serverMs = typeof state.expiresIn === "number" ? state.expiresIn * 1e3 : Infinity;
1307
1439
  const deadline = Date.now() + Math.min(timeoutMs, serverMs);
1440
+ let shown = state.qrUrl;
1441
+ let loading = null;
1442
+ const spent = () => qr.dataset.spent === "true";
1443
+ const showFrame = (url) => {
1444
+ if (url === shown || spent()) return;
1445
+ if (!shown) {
1446
+ qr.src = url;
1447
+ shown = url;
1448
+ return;
1449
+ }
1450
+ const next = new Image();
1451
+ loading = next;
1452
+ next.onload = () => {
1453
+ if (loading !== next || spent()) return;
1454
+ loading = null;
1455
+ qr.src = url;
1456
+ shown = url;
1457
+ };
1458
+ next.onerror = () => {
1459
+ if (loading === next) loading = null;
1460
+ };
1461
+ next.src = url;
1462
+ };
1308
1463
  const paint = (s) => {
1309
1464
  const settled = s.status === "claimed" || s.status === "enrolling";
1310
1465
  title.textContent = settled ? t.titleApprove : t.title;
1311
1466
  bodyText.textContent = s.status === "enrolling" ? t.bodyEnrolling : settled ? t.bodyApprove : t.bodyScan;
1312
1467
  statusLabel.textContent = settled ? t.waitingApproval : t.waiting;
1313
1468
  qr.dataset.spent = String(settled);
1469
+ halo.dataset.spent = String(settled);
1314
1470
  overlay.style.display = settled ? "" : "none";
1315
- if (s.qrUrl && qr.src !== s.qrUrl) qr.src = s.qrUrl;
1471
+ if (s.qrUrl) showFrame(s.qrUrl);
1316
1472
  };
1317
1473
  const tick = () => {
1318
1474
  const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1e3));
@@ -1329,6 +1485,7 @@ function mountPairingModal(state, options) {
1329
1485
  const close = () => {
1330
1486
  if (closed) return;
1331
1487
  closed = true;
1488
+ loading = null;
1332
1489
  window.clearInterval(interval);
1333
1490
  document.removeEventListener("keydown", onKey);
1334
1491
  document.body.style.overflow = previousOverflow;
@@ -1378,10 +1535,15 @@ function startLogin(options) {
1378
1535
  const flow = options.flow ?? "browser-direct";
1379
1536
  const issuer = options.issuer ?? DEFAULT_ISSUER;
1380
1537
  const controller = new AbortController();
1538
+ const useAppLink = options.display === "link" || options.display !== "qr" && isMobileUserAgent();
1381
1539
  const surface = {};
1382
1540
  const cancel = () => controller.abort();
1383
1541
  let modal = null;
1384
- const closeModal = () => {
1542
+ let stopRefresh = () => {
1543
+ };
1544
+ controller.signal.addEventListener("abort", () => stopRefresh());
1545
+ const teardown = () => {
1546
+ stopRefresh();
1385
1547
  modal?.close();
1386
1548
  modal = null;
1387
1549
  };
@@ -1402,7 +1564,8 @@ function startLogin(options) {
1402
1564
  acr_values: Array.isArray(options.acr_values) ? options.acr_values.join(" ") : options.acr_values,
1403
1565
  max_age: options.max_age,
1404
1566
  prompt: options.prompt,
1405
- locale: options.locale
1567
+ locale: options.locale,
1568
+ display: useAppLink ? "link" : "qr"
1406
1569
  },
1407
1570
  controller.signal
1408
1571
  );
@@ -1412,19 +1575,25 @@ function startLogin(options) {
1412
1575
  code = started.code;
1413
1576
  selectBy = "session";
1414
1577
  } else {
1415
- const useAppLink = options.display === "link" || options.display !== "qr" && isMobileUserAgent();
1416
1578
  selectBy = useAppLink ? "app_link" : "qr";
1417
- surface.requestId = started.request_id;
1579
+ const requestId = started.request_id;
1580
+ const qrBase = `${issuer}/pair/${encodeURIComponent(requestId)}/qr.svg`;
1581
+ const animated = !useAppLink && started.display !== "legacy";
1582
+ const qrRefreshSeconds = !animated ? void 0 : typeof started.qr_refresh_seconds === "number" && started.qr_refresh_seconds > 0 ? started.qr_refresh_seconds : DEFAULT_QR_REFRESH_SECONDS;
1583
+ surface.requestId = requestId;
1418
1584
  surface.pairUrl = started.pair_url;
1419
- surface.qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;
1585
+ surface.qrUrl = qrBase;
1420
1586
  surface.appLink = useAppLink;
1421
- const stateSurface = {
1587
+ const withSurface = (s) => ({
1588
+ ...s,
1422
1589
  pairUrl: surface.pairUrl,
1423
1590
  qrUrl: surface.qrUrl,
1591
+ qrRefreshSeconds,
1424
1592
  appLink: useAppLink,
1425
1593
  cancel
1426
- };
1427
- const initial = { status: "pending", expiresIn: started.expires_in, ...stateSurface };
1594
+ });
1595
+ let lastPolled = { status: "pending", expiresIn: started.expires_in };
1596
+ const initial = withSurface(lastPolled);
1428
1597
  options.onState?.(initial);
1429
1598
  if ((options.pairingUI ?? "modal") === "modal" && !useAppLink) {
1430
1599
  modal = mountPairingModal(initial, {
@@ -1437,18 +1606,53 @@ function startLogin(options) {
1437
1606
  if (useAppLink && typeof window !== "undefined") {
1438
1607
  window.location.assign(started.pair_url);
1439
1608
  }
1609
+ if (qrRefreshSeconds !== void 0) {
1610
+ const periodMs = qrRefreshSeconds * 1e3;
1611
+ let due = Date.now() + periodMs;
1612
+ let timer;
1613
+ let stopped = false;
1614
+ const emit = () => {
1615
+ timer = void 0;
1616
+ surface.qrUrl = `${qrBase}?t=${Date.now()}`;
1617
+ const next = withSurface(lastPolled);
1618
+ modal?.update(next);
1619
+ options.onState?.(next);
1620
+ if (stopped) return;
1621
+ due = Date.now() + periodMs;
1622
+ timer = setTimeout(emit, periodMs);
1623
+ };
1624
+ const onVisible = () => {
1625
+ if (document.visibilityState === "visible" && timer !== void 0 && Date.now() >= due) {
1626
+ clearTimeout(timer);
1627
+ emit();
1628
+ }
1629
+ };
1630
+ const hasDocument = typeof document !== "undefined";
1631
+ if (hasDocument) document.addEventListener("visibilitychange", onVisible);
1632
+ stopRefresh = () => {
1633
+ stopped = true;
1634
+ if (timer !== void 0) clearTimeout(timer);
1635
+ timer = void 0;
1636
+ if (hasDocument) document.removeEventListener("visibilitychange", onVisible);
1637
+ stopRefresh = () => {
1638
+ };
1639
+ };
1640
+ timer = setTimeout(emit, Math.max(0, due - Date.now()));
1641
+ }
1440
1642
  code = await pollUntilApproved(
1441
1643
  issuer,
1442
- started.request_id,
1644
+ requestId,
1443
1645
  (s) => {
1444
- const next = { ...s, ...stateSurface };
1646
+ lastPolled = s;
1647
+ if (s.status !== "pending") stopRefresh();
1648
+ const next = withSurface(s);
1445
1649
  modal?.update(next);
1446
1650
  options.onState?.(next);
1447
1651
  },
1448
1652
  controller.signal
1449
1653
  );
1450
1654
  }
1451
- closeModal();
1655
+ teardown();
1452
1656
  if (flow === "auth-code") {
1453
1657
  const response2 = {
1454
1658
  code,
@@ -1473,7 +1677,7 @@ function startLogin(options) {
1473
1677
  };
1474
1678
  return response;
1475
1679
  } catch (e) {
1476
- closeModal();
1680
+ teardown();
1477
1681
  if (e instanceof DOMException && e.name === "AbortError") throw e;
1478
1682
  if (e instanceof OAuthFlowError || e instanceof FlowAbandonedError) throw e;
1479
1683
  throw new FlowAbandonedError({
@@ -1506,6 +1710,7 @@ function startLogin(options) {
1506
1710
  0 && (module.exports = {
1507
1711
  DEFAULT_ISSUER,
1508
1712
  DEFAULT_PAIRING_TIMEOUT_MS,
1713
+ DEFAULT_QR_REFRESH_SECONDS,
1509
1714
  FlowAbandonedError,
1510
1715
  OAuthFlowError,
1511
1716
  POLL_INTERVAL_ENROLLING_MS,