@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/dist/index.js CHANGED
@@ -14,11 +14,12 @@ function unsafeClaims(idToken) {
14
14
 
15
15
  // src/wire.ts
16
16
  var WIRE_VERSION = 1;
17
- var SDK_VERSION = "0.1.9";
17
+ var SDK_VERSION = "0.1.11";
18
18
  var SDK_NAME = "@zoreal/oauth2-js";
19
19
  var DEFAULT_ISSUER = "https://id.zoreal.com";
20
20
  var POLL_INTERVAL_MS = 2e3;
21
21
  var POLL_INTERVAL_ENROLLING_MS = 5e3;
22
+ var DEFAULT_QR_REFRESH_SECONDS = 3;
22
23
 
23
24
  // src/pairing.ts
24
25
  var OAuthFlowError = class extends Error {
@@ -67,11 +68,15 @@ var sleep = (ms, signal) => new Promise((resolve, reject) => {
67
68
  reject(new DOMException("aborted", "AbortError"));
68
69
  return;
69
70
  }
70
- const t = setTimeout(resolve, ms);
71
- signal?.addEventListener("abort", () => {
71
+ const onAbort = () => {
72
72
  clearTimeout(t);
73
73
  reject(new DOMException("aborted", "AbortError"));
74
- });
74
+ };
75
+ const t = setTimeout(() => {
76
+ signal?.removeEventListener("abort", onAbort);
77
+ resolve();
78
+ }, ms);
79
+ signal?.addEventListener("abort", onAbort, { once: true });
75
80
  });
76
81
  async function pollUntilApproved(issuer, requestId, onState, signal) {
77
82
  for (; ; ) {
@@ -962,20 +967,110 @@ var CSS = `
962
967
  color: var(--zrl-ink-soft);
963
968
  }
964
969
 
970
+ /* The light around the well: a thin ring in the accent, one bright arc
971
+ travelling round it, and a soft glow of the same colour bleeding a few
972
+ pixels outward. It says the pairing is live without a word.
973
+
974
+ It sits BELOW the well, which is why the well is wrapped instead of given a
975
+ pseudo-element. Anything drawn inside the well paints over the white quiet
976
+ zone the camera needs, and a negative z-index from inside the well lands
977
+ wherever the card's stacking context happens to be that frame (the card's
978
+ entrance animation makes and unmakes one). The wrapper is the well's old
979
+ box exactly, same size, same margin, so nothing moves; the well keeps its
980
+ own stacking order on top, and the ring cannot touch the QR. */
981
+ .${PREFIX}-qr-halo {
982
+ position: relative;
983
+ isolation: isolate;
984
+ width: 204px;
985
+ height: 204px;
986
+ margin: 20px auto 0;
987
+ }
988
+
965
989
  .${PREFIX}-qr-well {
966
990
  position: relative;
991
+ z-index: 1;
967
992
  display: grid;
968
993
  place-items: center;
969
994
  box-sizing: border-box;
970
995
  width: 204px;
971
996
  height: 204px;
972
- margin: 20px auto 0;
997
+ margin: 0;
973
998
  padding: 12px;
974
999
  border: 1px solid var(--zrl-line);
975
1000
  border-radius: 16px;
976
1001
  background: var(--zrl-qr-bg);
977
1002
  }
978
1003
 
1004
+ /* The glow is the ring's own alpha, blurred and drawn beneath it, so it
1005
+ follows the arc round and fades with it. It has to be a parent of the
1006
+ masked band: a filter is applied before a mask, so a shadow cast from the
1007
+ band itself would be cut off at the band's edge. Two shadows because one
1008
+ 2px source blurred over 10px is too faint to read as light; the second
1009
+ blurs the first. */
1010
+ .${PREFIX}-qr-glow {
1011
+ position: absolute;
1012
+ inset: -2px;
1013
+ pointer-events: none;
1014
+ filter: drop-shadow(0 0 3px var(--zrl-accent)) drop-shadow(0 0 10px var(--zrl-accent));
1015
+ }
1016
+
1017
+ /* The band: a 2px ring just outside the well, cut with a mask that keeps the
1018
+ padding and drops the content box. The gradient is on a pseudo-element
1019
+ rather than on the band, because in the fallback it has to rotate as a
1020
+ whole while the band stays put. The prefixed mask is for Chrome before 120;
1021
+ the unprefixed one, declared after it, wins everywhere else. */
1022
+ .${PREFIX}-qr-arc {
1023
+ position: absolute;
1024
+ inset: 0;
1025
+ padding: 2px;
1026
+ border-radius: 18px;
1027
+ overflow: hidden;
1028
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
1029
+ -webkit-mask-composite: xor;
1030
+ mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
1031
+ mask-composite: exclude;
1032
+ }
1033
+ .${PREFIX}-qr-arc::before,
1034
+ .${PREFIX}-qr-arc::after {
1035
+ content: '';
1036
+ position: absolute;
1037
+ transition: opacity 400ms cubic-bezier(0.23, 1, 0.32, 1);
1038
+ }
1039
+
1040
+ /* The light. Mostly transparent, a tail that builds over a quarter turn, a
1041
+ short full-strength head and a drop-off within a few degrees, so it reads
1042
+ as travelling rather than blinking. One revolution every 3s, linear:
1043
+ continuous motion has no ease. Oversized so its corners still cover the
1044
+ band mid-rotation in the transform fallback; the property path shrinks it
1045
+ back to the band. */
1046
+ .${PREFIX}-qr-arc::before {
1047
+ inset: -50%;
1048
+ background: conic-gradient(
1049
+ from var(--zrl-angle, 0deg),
1050
+ transparent 0deg 245deg,
1051
+ var(--zrl-accent) 332deg 346deg,
1052
+ transparent 356deg 360deg
1053
+ );
1054
+ animation: ${PREFIX}-orbit 3s linear infinite;
1055
+ }
1056
+
1057
+ /* The rest state: the same ring, evenly lit. Faint under the moving light so
1058
+ the ring reads as a ring; stronger once the light has stopped. */
1059
+ .${PREFIX}-qr-arc::after {
1060
+ inset: 0;
1061
+ background: var(--zrl-accent);
1062
+ opacity: 0.16;
1063
+ }
1064
+
1065
+ /* Spent: the light stops where it is and fades, and the ring settles to a
1066
+ dim, even glow. Paused rather than removed, so it does not jump back to
1067
+ its start on the way out. */
1068
+ .${PREFIX}-qr-halo[data-spent="true"] .${PREFIX}-qr-arc::before {
1069
+ animation-play-state: paused;
1070
+ opacity: 0;
1071
+ }
1072
+ .${PREFIX}-qr-halo[data-spent="true"] .${PREFIX}-qr-arc::after { opacity: 0.34; }
1073
+
979
1074
  .${PREFIX}-qr {
980
1075
  display: block;
981
1076
  width: 100%;
@@ -1118,6 +1213,33 @@ var CSS = `
1118
1213
  70%, 100% { transform: scale(2.6); opacity: 0 }
1119
1214
  }
1120
1215
 
1216
+ /* Two ways to turn the light, one look.
1217
+ Where the browser can register a typed custom property, the gradient's own
1218
+ angle animates and the element never moves. Where it cannot (Firefox before
1219
+ 128), an untyped custom property cannot interpolate and the light would
1220
+ snap once a cycle, so the oversized pseudo-element rotates instead and the
1221
+ band's mask keeps the ring in place. At rest the two are the same pixels:
1222
+ same gradient, same centre, angle zero.
1223
+ There is no direct query for @property support. transition-behavior
1224
+ shipped after it in every engine (Chrome 117 vs 85, Firefox 129 vs 128,
1225
+ Safari 17.4 vs 16.4), so it stands in: a yes is never wrong, and a no only
1226
+ sends a capable browser down the fallback, which looks the same.
1227
+ inherits: false so the angle stays on the element that animates it and
1228
+ never reaches the host page or the well. */
1229
+ @property --zrl-angle {
1230
+ syntax: '<angle>';
1231
+ inherits: false;
1232
+ initial-value: 0deg;
1233
+ }
1234
+ @keyframes ${PREFIX}-orbit { to { transform: rotate(360deg) } }
1235
+ @keyframes ${PREFIX}-orbit-angle { to { --zrl-angle: 360deg } }
1236
+ @supports (transition-behavior: allow-discrete) {
1237
+ .${PREFIX}-qr-arc::before {
1238
+ inset: 0;
1239
+ animation-name: ${PREFIX}-orbit-angle;
1240
+ }
1241
+ }
1242
+
1121
1243
  @media (prefers-reduced-motion: reduce) {
1122
1244
  .${PREFIX}-scrim,
1123
1245
  .${PREFIX}-card,
@@ -1127,6 +1249,10 @@ var CSS = `
1127
1249
  .${PREFIX}-cancel,
1128
1250
  .${PREFIX}-close,
1129
1251
  .${PREFIX}-timer { transition: none }
1252
+ /* No travelling light. The ring keeps a still, soft glow instead, a step
1253
+ brighter than the spent state so pending and spent still read apart. */
1254
+ .${PREFIX}-qr-arc::before { animation: none; opacity: 0 }
1255
+ .${PREFIX}-qr-arc::after { opacity: 0.5 }
1130
1256
  }
1131
1257
  `;
1132
1258
  function ensureStyles() {
@@ -1231,6 +1357,10 @@ function mountPairingModal(state, options) {
1231
1357
  const title = el("h2", cx("title"));
1232
1358
  title.id = titleId;
1233
1359
  const bodyText = el("p", cx("body-text"));
1360
+ const halo = el("div", cx("qr-halo"));
1361
+ const glow = el("span", cx("qr-glow"));
1362
+ glow.setAttribute("aria-hidden", "true");
1363
+ glow.appendChild(el("span", cx("qr-arc")));
1234
1364
  const well = el("div", cx("qr-well"));
1235
1365
  const qr = el("img", cx("qr"));
1236
1366
  qr.alt = t.qrAlt;
@@ -1242,13 +1372,14 @@ function mountPairingModal(state, options) {
1242
1372
  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"));
1243
1373
  overlay.appendChild(badge);
1244
1374
  well.append(qr, overlay);
1375
+ halo.append(glow, well);
1245
1376
  const status = el("div", cx("status"));
1246
1377
  const dot = el("span", cx("dot"));
1247
1378
  dot.append(el("i"), el("i"));
1248
1379
  const statusLabel = el("span");
1249
1380
  status.append(dot, statusLabel);
1250
1381
  const timer = el("p", cx("timer"));
1251
- body.append(mark, title, bodyText, well, status, timer);
1382
+ body.append(mark, title, bodyText, halo, status, timer);
1252
1383
  const help = el("div", cx("help"));
1253
1384
  help.append(el("p", cx("help-title"), t.noIdTitle), el("p", cx("help-body"), t.noIdBody));
1254
1385
  const footer = el("div", cx("footer"));
@@ -1261,14 +1392,38 @@ function mountPairingModal(state, options) {
1261
1392
  scrim.appendChild(card);
1262
1393
  const serverMs = typeof state.expiresIn === "number" ? state.expiresIn * 1e3 : Infinity;
1263
1394
  const deadline = Date.now() + Math.min(timeoutMs, serverMs);
1395
+ let shown = state.qrUrl;
1396
+ let loading = null;
1397
+ const spent = () => qr.dataset.spent === "true";
1398
+ const showFrame = (url) => {
1399
+ if (url === shown || spent()) return;
1400
+ if (!shown) {
1401
+ qr.src = url;
1402
+ shown = url;
1403
+ return;
1404
+ }
1405
+ const next = new Image();
1406
+ loading = next;
1407
+ next.onload = () => {
1408
+ if (loading !== next || spent()) return;
1409
+ loading = null;
1410
+ qr.src = url;
1411
+ shown = url;
1412
+ };
1413
+ next.onerror = () => {
1414
+ if (loading === next) loading = null;
1415
+ };
1416
+ next.src = url;
1417
+ };
1264
1418
  const paint = (s) => {
1265
1419
  const settled = s.status === "claimed" || s.status === "enrolling";
1266
1420
  title.textContent = settled ? t.titleApprove : t.title;
1267
1421
  bodyText.textContent = s.status === "enrolling" ? t.bodyEnrolling : settled ? t.bodyApprove : t.bodyScan;
1268
1422
  statusLabel.textContent = settled ? t.waitingApproval : t.waiting;
1269
1423
  qr.dataset.spent = String(settled);
1424
+ halo.dataset.spent = String(settled);
1270
1425
  overlay.style.display = settled ? "" : "none";
1271
- if (s.qrUrl && qr.src !== s.qrUrl) qr.src = s.qrUrl;
1426
+ if (s.qrUrl) showFrame(s.qrUrl);
1272
1427
  };
1273
1428
  const tick = () => {
1274
1429
  const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1e3));
@@ -1285,6 +1440,7 @@ function mountPairingModal(state, options) {
1285
1440
  const close = () => {
1286
1441
  if (closed) return;
1287
1442
  closed = true;
1443
+ loading = null;
1288
1444
  window.clearInterval(interval);
1289
1445
  document.removeEventListener("keydown", onKey);
1290
1446
  document.body.style.overflow = previousOverflow;
@@ -1334,10 +1490,15 @@ function startLogin(options) {
1334
1490
  const flow = options.flow ?? "browser-direct";
1335
1491
  const issuer = options.issuer ?? DEFAULT_ISSUER;
1336
1492
  const controller = new AbortController();
1493
+ const useAppLink = options.display === "link" || options.display !== "qr" && isMobileUserAgent();
1337
1494
  const surface = {};
1338
1495
  const cancel = () => controller.abort();
1339
1496
  let modal = null;
1340
- const closeModal = () => {
1497
+ let stopRefresh = () => {
1498
+ };
1499
+ controller.signal.addEventListener("abort", () => stopRefresh());
1500
+ const teardown = () => {
1501
+ stopRefresh();
1341
1502
  modal?.close();
1342
1503
  modal = null;
1343
1504
  };
@@ -1358,7 +1519,8 @@ function startLogin(options) {
1358
1519
  acr_values: Array.isArray(options.acr_values) ? options.acr_values.join(" ") : options.acr_values,
1359
1520
  max_age: options.max_age,
1360
1521
  prompt: options.prompt,
1361
- locale: options.locale
1522
+ locale: options.locale,
1523
+ display: useAppLink ? "link" : "qr"
1362
1524
  },
1363
1525
  controller.signal
1364
1526
  );
@@ -1368,19 +1530,25 @@ function startLogin(options) {
1368
1530
  code = started.code;
1369
1531
  selectBy = "session";
1370
1532
  } else {
1371
- const useAppLink = options.display === "link" || options.display !== "qr" && isMobileUserAgent();
1372
1533
  selectBy = useAppLink ? "app_link" : "qr";
1373
- surface.requestId = started.request_id;
1534
+ const requestId = started.request_id;
1535
+ const qrBase = `${issuer}/pair/${encodeURIComponent(requestId)}/qr.svg`;
1536
+ const animated = !useAppLink && started.display !== "legacy";
1537
+ const qrRefreshSeconds = !animated ? void 0 : typeof started.qr_refresh_seconds === "number" && started.qr_refresh_seconds > 0 ? started.qr_refresh_seconds : DEFAULT_QR_REFRESH_SECONDS;
1538
+ surface.requestId = requestId;
1374
1539
  surface.pairUrl = started.pair_url;
1375
- surface.qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;
1540
+ surface.qrUrl = qrBase;
1376
1541
  surface.appLink = useAppLink;
1377
- const stateSurface = {
1542
+ const withSurface = (s) => ({
1543
+ ...s,
1378
1544
  pairUrl: surface.pairUrl,
1379
1545
  qrUrl: surface.qrUrl,
1546
+ qrRefreshSeconds,
1380
1547
  appLink: useAppLink,
1381
1548
  cancel
1382
- };
1383
- const initial = { status: "pending", expiresIn: started.expires_in, ...stateSurface };
1549
+ });
1550
+ let lastPolled = { status: "pending", expiresIn: started.expires_in };
1551
+ const initial = withSurface(lastPolled);
1384
1552
  options.onState?.(initial);
1385
1553
  if ((options.pairingUI ?? "modal") === "modal" && !useAppLink) {
1386
1554
  modal = mountPairingModal(initial, {
@@ -1393,18 +1561,53 @@ function startLogin(options) {
1393
1561
  if (useAppLink && typeof window !== "undefined") {
1394
1562
  window.location.assign(started.pair_url);
1395
1563
  }
1564
+ if (qrRefreshSeconds !== void 0) {
1565
+ const periodMs = qrRefreshSeconds * 1e3;
1566
+ let due = Date.now() + periodMs;
1567
+ let timer;
1568
+ let stopped = false;
1569
+ const emit = () => {
1570
+ timer = void 0;
1571
+ surface.qrUrl = `${qrBase}?t=${Date.now()}`;
1572
+ const next = withSurface(lastPolled);
1573
+ modal?.update(next);
1574
+ options.onState?.(next);
1575
+ if (stopped) return;
1576
+ due = Date.now() + periodMs;
1577
+ timer = setTimeout(emit, periodMs);
1578
+ };
1579
+ const onVisible = () => {
1580
+ if (document.visibilityState === "visible" && timer !== void 0 && Date.now() >= due) {
1581
+ clearTimeout(timer);
1582
+ emit();
1583
+ }
1584
+ };
1585
+ const hasDocument = typeof document !== "undefined";
1586
+ if (hasDocument) document.addEventListener("visibilitychange", onVisible);
1587
+ stopRefresh = () => {
1588
+ stopped = true;
1589
+ if (timer !== void 0) clearTimeout(timer);
1590
+ timer = void 0;
1591
+ if (hasDocument) document.removeEventListener("visibilitychange", onVisible);
1592
+ stopRefresh = () => {
1593
+ };
1594
+ };
1595
+ timer = setTimeout(emit, Math.max(0, due - Date.now()));
1596
+ }
1396
1597
  code = await pollUntilApproved(
1397
1598
  issuer,
1398
- started.request_id,
1599
+ requestId,
1399
1600
  (s) => {
1400
- const next = { ...s, ...stateSurface };
1601
+ lastPolled = s;
1602
+ if (s.status !== "pending") stopRefresh();
1603
+ const next = withSurface(s);
1401
1604
  modal?.update(next);
1402
1605
  options.onState?.(next);
1403
1606
  },
1404
1607
  controller.signal
1405
1608
  );
1406
1609
  }
1407
- closeModal();
1610
+ teardown();
1408
1611
  if (flow === "auth-code") {
1409
1612
  const response2 = {
1410
1613
  code,
@@ -1429,7 +1632,7 @@ function startLogin(options) {
1429
1632
  };
1430
1633
  return response;
1431
1634
  } catch (e) {
1432
- closeModal();
1635
+ teardown();
1433
1636
  if (e instanceof DOMException && e.name === "AbortError") throw e;
1434
1637
  if (e instanceof OAuthFlowError || e instanceof FlowAbandonedError) throw e;
1435
1638
  throw new FlowAbandonedError({
@@ -1461,6 +1664,7 @@ function startLogin(options) {
1461
1664
  export {
1462
1665
  DEFAULT_ISSUER,
1463
1666
  DEFAULT_PAIRING_TIMEOUT_MS,
1667
+ DEFAULT_QR_REFRESH_SECONDS,
1464
1668
  FlowAbandonedError,
1465
1669
  OAuthFlowError,
1466
1670
  POLL_INTERVAL_ENROLLING_MS,