@zoreal/oauth2-js 0.1.9 → 0.1.10

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.10";
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 (; ; ) {
@@ -1305,6 +1311,29 @@ function mountPairingModal(state, options) {
1305
1311
  scrim.appendChild(card);
1306
1312
  const serverMs = typeof state.expiresIn === "number" ? state.expiresIn * 1e3 : Infinity;
1307
1313
  const deadline = Date.now() + Math.min(timeoutMs, serverMs);
1314
+ let shown = state.qrUrl;
1315
+ let loading = null;
1316
+ const spent = () => qr.dataset.spent === "true";
1317
+ const showFrame = (url) => {
1318
+ if (url === shown || spent()) return;
1319
+ if (!shown) {
1320
+ qr.src = url;
1321
+ shown = url;
1322
+ return;
1323
+ }
1324
+ const next = new Image();
1325
+ loading = next;
1326
+ next.onload = () => {
1327
+ if (loading !== next || spent()) return;
1328
+ loading = null;
1329
+ qr.src = url;
1330
+ shown = url;
1331
+ };
1332
+ next.onerror = () => {
1333
+ if (loading === next) loading = null;
1334
+ };
1335
+ next.src = url;
1336
+ };
1308
1337
  const paint = (s) => {
1309
1338
  const settled = s.status === "claimed" || s.status === "enrolling";
1310
1339
  title.textContent = settled ? t.titleApprove : t.title;
@@ -1312,7 +1341,7 @@ function mountPairingModal(state, options) {
1312
1341
  statusLabel.textContent = settled ? t.waitingApproval : t.waiting;
1313
1342
  qr.dataset.spent = String(settled);
1314
1343
  overlay.style.display = settled ? "" : "none";
1315
- if (s.qrUrl && qr.src !== s.qrUrl) qr.src = s.qrUrl;
1344
+ if (s.qrUrl) showFrame(s.qrUrl);
1316
1345
  };
1317
1346
  const tick = () => {
1318
1347
  const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1e3));
@@ -1329,6 +1358,7 @@ function mountPairingModal(state, options) {
1329
1358
  const close = () => {
1330
1359
  if (closed) return;
1331
1360
  closed = true;
1361
+ loading = null;
1332
1362
  window.clearInterval(interval);
1333
1363
  document.removeEventListener("keydown", onKey);
1334
1364
  document.body.style.overflow = previousOverflow;
@@ -1378,10 +1408,15 @@ function startLogin(options) {
1378
1408
  const flow = options.flow ?? "browser-direct";
1379
1409
  const issuer = options.issuer ?? DEFAULT_ISSUER;
1380
1410
  const controller = new AbortController();
1411
+ const useAppLink = options.display === "link" || options.display !== "qr" && isMobileUserAgent();
1381
1412
  const surface = {};
1382
1413
  const cancel = () => controller.abort();
1383
1414
  let modal = null;
1384
- const closeModal = () => {
1415
+ let stopRefresh = () => {
1416
+ };
1417
+ controller.signal.addEventListener("abort", () => stopRefresh());
1418
+ const teardown = () => {
1419
+ stopRefresh();
1385
1420
  modal?.close();
1386
1421
  modal = null;
1387
1422
  };
@@ -1402,7 +1437,8 @@ function startLogin(options) {
1402
1437
  acr_values: Array.isArray(options.acr_values) ? options.acr_values.join(" ") : options.acr_values,
1403
1438
  max_age: options.max_age,
1404
1439
  prompt: options.prompt,
1405
- locale: options.locale
1440
+ locale: options.locale,
1441
+ display: useAppLink ? "link" : "qr"
1406
1442
  },
1407
1443
  controller.signal
1408
1444
  );
@@ -1412,19 +1448,25 @@ function startLogin(options) {
1412
1448
  code = started.code;
1413
1449
  selectBy = "session";
1414
1450
  } else {
1415
- const useAppLink = options.display === "link" || options.display !== "qr" && isMobileUserAgent();
1416
1451
  selectBy = useAppLink ? "app_link" : "qr";
1417
- surface.requestId = started.request_id;
1452
+ const requestId = started.request_id;
1453
+ const qrBase = `${issuer}/pair/${encodeURIComponent(requestId)}/qr.svg`;
1454
+ const animated = !useAppLink && started.display !== "legacy";
1455
+ const qrRefreshSeconds = !animated ? void 0 : typeof started.qr_refresh_seconds === "number" && started.qr_refresh_seconds > 0 ? started.qr_refresh_seconds : DEFAULT_QR_REFRESH_SECONDS;
1456
+ surface.requestId = requestId;
1418
1457
  surface.pairUrl = started.pair_url;
1419
- surface.qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;
1458
+ surface.qrUrl = qrBase;
1420
1459
  surface.appLink = useAppLink;
1421
- const stateSurface = {
1460
+ const withSurface = (s) => ({
1461
+ ...s,
1422
1462
  pairUrl: surface.pairUrl,
1423
1463
  qrUrl: surface.qrUrl,
1464
+ qrRefreshSeconds,
1424
1465
  appLink: useAppLink,
1425
1466
  cancel
1426
- };
1427
- const initial = { status: "pending", expiresIn: started.expires_in, ...stateSurface };
1467
+ });
1468
+ let lastPolled = { status: "pending", expiresIn: started.expires_in };
1469
+ const initial = withSurface(lastPolled);
1428
1470
  options.onState?.(initial);
1429
1471
  if ((options.pairingUI ?? "modal") === "modal" && !useAppLink) {
1430
1472
  modal = mountPairingModal(initial, {
@@ -1437,18 +1479,53 @@ function startLogin(options) {
1437
1479
  if (useAppLink && typeof window !== "undefined") {
1438
1480
  window.location.assign(started.pair_url);
1439
1481
  }
1482
+ if (qrRefreshSeconds !== void 0) {
1483
+ const periodMs = qrRefreshSeconds * 1e3;
1484
+ let due = Date.now() + periodMs;
1485
+ let timer;
1486
+ let stopped = false;
1487
+ const emit = () => {
1488
+ timer = void 0;
1489
+ surface.qrUrl = `${qrBase}?t=${Date.now()}`;
1490
+ const next = withSurface(lastPolled);
1491
+ modal?.update(next);
1492
+ options.onState?.(next);
1493
+ if (stopped) return;
1494
+ due = Date.now() + periodMs;
1495
+ timer = setTimeout(emit, periodMs);
1496
+ };
1497
+ const onVisible = () => {
1498
+ if (document.visibilityState === "visible" && timer !== void 0 && Date.now() >= due) {
1499
+ clearTimeout(timer);
1500
+ emit();
1501
+ }
1502
+ };
1503
+ const hasDocument = typeof document !== "undefined";
1504
+ if (hasDocument) document.addEventListener("visibilitychange", onVisible);
1505
+ stopRefresh = () => {
1506
+ stopped = true;
1507
+ if (timer !== void 0) clearTimeout(timer);
1508
+ timer = void 0;
1509
+ if (hasDocument) document.removeEventListener("visibilitychange", onVisible);
1510
+ stopRefresh = () => {
1511
+ };
1512
+ };
1513
+ timer = setTimeout(emit, Math.max(0, due - Date.now()));
1514
+ }
1440
1515
  code = await pollUntilApproved(
1441
1516
  issuer,
1442
- started.request_id,
1517
+ requestId,
1443
1518
  (s) => {
1444
- const next = { ...s, ...stateSurface };
1519
+ lastPolled = s;
1520
+ if (s.status !== "pending") stopRefresh();
1521
+ const next = withSurface(s);
1445
1522
  modal?.update(next);
1446
1523
  options.onState?.(next);
1447
1524
  },
1448
1525
  controller.signal
1449
1526
  );
1450
1527
  }
1451
- closeModal();
1528
+ teardown();
1452
1529
  if (flow === "auth-code") {
1453
1530
  const response2 = {
1454
1531
  code,
@@ -1473,7 +1550,7 @@ function startLogin(options) {
1473
1550
  };
1474
1551
  return response;
1475
1552
  } catch (e) {
1476
- closeModal();
1553
+ teardown();
1477
1554
  if (e instanceof DOMException && e.name === "AbortError") throw e;
1478
1555
  if (e instanceof OAuthFlowError || e instanceof FlowAbandonedError) throw e;
1479
1556
  throw new FlowAbandonedError({
@@ -1506,6 +1583,7 @@ function startLogin(options) {
1506
1583
  0 && (module.exports = {
1507
1584
  DEFAULT_ISSUER,
1508
1585
  DEFAULT_PAIRING_TIMEOUT_MS,
1586
+ DEFAULT_QR_REFRESH_SECONDS,
1509
1587
  FlowAbandonedError,
1510
1588
  OAuthFlowError,
1511
1589
  POLL_INTERVAL_ENROLLING_MS,