@zoreal/oauth2-js 0.1.8 → 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/dist/index.d.cts CHANGED
@@ -31,11 +31,28 @@ interface PairingState {
31
31
  /**
32
32
  * The pairing link and its provider-served QR image. Present on every
33
33
  * callback of a QR/link flow: the QR flow cannot complete unless SOMETHING
34
- * renders pairUrl, and in this package that something is always the caller.
34
+ * renders the code. By default that something is this package's own modal;
35
+ * these fields are what you render from instead when you opt out with
36
+ * `pairingUI: 'none'`.
35
37
  */
36
38
  pairUrl?: string;
37
- /** The provider-served SVG of pairUrl. Put it in an <img>; do not draw your own. */
39
+ /**
40
+ * The provider-served SVG of the code to scan. Put it in an <img>; do not
41
+ * draw your own. It CHANGES OVER TIME: while the pairing is pending a fresh
42
+ * URL arrives every `qrRefreshSeconds`, because the code on screen moves
43
+ * and the provider refuses a frame older than 30 seconds. That is what
44
+ * makes a screenshot of it useless to an attacker. Render the qrUrl you
45
+ * are given on every state; a UI that caches the first one shows a code
46
+ * that stops working.
47
+ */
38
48
  qrUrl?: string;
49
+ /**
50
+ * How often qrUrl changes, in seconds, while the pairing is pending.
51
+ * Present on a QR flow, absent on an app-link flow and on a provider that
52
+ * still serves a static code. Informational: the refresh itself is done for
53
+ * you and arrives through this callback.
54
+ */
55
+ qrRefreshSeconds?: number;
39
56
  /** True when the flow resolved to the app link (mobile) rather than a QR. */
40
57
  appLink?: boolean;
41
58
  /** Abandons this pairing: stops the poll. Wire it to your UI's cancel control. */
@@ -153,9 +170,13 @@ interface LoginHandle<T> {
153
170
  cancel: () => void;
154
171
  /** The pairing request id, once created. Undefined before, and for prompt=none immediate codes. */
155
172
  readonly requestId: string | undefined;
156
- /** The pairing URL, once created. The same URL in QR and app link. */
173
+ /** The pairing URL, once created. On an app-link flow it carries the start token; navigate to it verbatim. */
157
174
  readonly pairUrl: string | undefined;
158
- /** The provider-served QR image of pairUrl. */
175
+ /**
176
+ * The provider-served QR image, current frame. Changes every few seconds
177
+ * while the pairing is pending; the onState callback is where a UI should
178
+ * read it from, since that is the only place a new frame announces itself.
179
+ */
159
180
  readonly qrUrl: string | undefined;
160
181
  /** True when display resolved to the app link rather than the QR. */
161
182
  readonly appLink: boolean | undefined;
@@ -222,36 +243,86 @@ declare function mountPairingModal(state: PairingState, options: PairingModalOpt
222
243
  * authorized JavaScript origins (the dashboard):
223
244
  *
224
245
  * POST /pair start a pairing request. Body carries the
225
- * authorize parameters plus PKCE challenge.
226
- * Returns { request_id, pair_url, expires_in }
227
- * or, for prompt=none with a live consented
228
- * session, { code } immediately.
246
+ * authorize parameters plus PKCE challenge,
247
+ * and `display`: which pairing surface this
248
+ * package is about to show, 'qr' or 'link',
249
+ * decided before the request is made. Returns
250
+ * { request_id, pair_url, expires_in, display,
251
+ * qr_refresh_seconds } or, for prompt=none
252
+ * with a live consented session, { code }
253
+ * immediately. `display` is echoed as the
254
+ * provider bound it ('legacy' for a request
255
+ * that sent none, which gets the static code
256
+ * older versions of this package showed).
257
+ * `qr_refresh_seconds` comes with 'qr' and is
258
+ * how often to re-fetch the image; 3 today.
259
+ * A 'link' pairing's pair_url carries
260
+ * ?t=<start_token>: it can only be claimed by
261
+ * the app that opened that exact link, and
262
+ * the provider never renders a QR for it, so
263
+ * nobody can turn a same-device link into a
264
+ * static code to relay.
229
265
  * GET /pair/:id/status poll: pending | claimed |
230
266
  * approved (with code) | denied | expired |
231
267
  * enrolling. Over-polling cancels the request
232
268
  * rather than throttling it, so the cadence
233
269
  * below is not a suggestion.
234
- * GET /pair/:id/qr.svg the QR image for the pairing URL, served by
235
- * the provider so the pairing surface stays
236
- * changeable at runtime and
237
- * this package keeps zero dependencies.
270
+ * GET /pair/:id/qr.svg the QR image, rendered by the provider so
271
+ * this package draws nothing and keeps zero
272
+ * dependencies. For a 'qr' pairing it encodes
273
+ * the CURRENT FRAME, the pairing URL with
274
+ * ?f=<time>.<hmac>: `time` is whole seconds
275
+ * since the pairing was created on the
276
+ * provider's clock, `hmac` is keyed with a
277
+ * secret only the provider holds. Served
278
+ * Cache-Control: no-store, only while the
279
+ * pairing is pending. The app sends the frame
280
+ * it scanned with its claim, and the provider
281
+ * refuses a frame older than 30 seconds, so a
282
+ * screenshot of the code is dead on arrival.
283
+ * This package re-fetches the image every
284
+ * `qr_refresh_seconds` with a cache-busting
285
+ * ?t=<Date.now()>. A 'link' pairing has no
286
+ * image (404).
238
287
  * POST /token the code exchange. Browser-direct mode uses
239
288
  * it directly with PKCE and no client secret;
240
289
  * auth-code mode leaves it to the RP backend.
241
290
  */
242
291
  declare const WIRE_VERSION = 1;
243
- declare const SDK_VERSION = "0.1.8";
292
+ declare const SDK_VERSION = "0.1.10";
244
293
  declare const SDK_NAME = "@zoreal/oauth2-js";
245
294
  declare const DEFAULT_ISSUER = "https://id.zoreal.com";
246
295
  /** Pending TTL is short. Poll gently; over-polling cancels the request. */
247
296
  declare const POLL_INTERVAL_MS = 2000;
248
297
  /** Enrolling extends the window well beyond a normal login; poll slower. */
249
298
  declare const POLL_INTERVAL_ENROLLING_MS = 5000;
299
+ /**
300
+ * How often the QR image is re-fetched while a pairing is pending, when the
301
+ * provider does not say. The provider's own `qr_refresh_seconds` wins when
302
+ * present. Refreshing is what makes the code on screen move, and a frame the
303
+ * provider refuses after 30 seconds is what makes a screenshot of it useless.
304
+ */
305
+ declare const DEFAULT_QR_REFRESH_SECONDS = 3;
306
+ /** The pairing surface this package will show, sent on POST /pair. */
307
+ type PairDisplay = 'qr' | 'link';
250
308
  interface PairCreated {
251
309
  request_id: string;
252
- /** https://zoreal.com/qr/<request_id>. The same URL in QR and app link. */
310
+ /**
311
+ * https://zoreal.com/login/<request_id>. For a 'link' pairing the URL also
312
+ * carries ?t=<start_token>, and only the app that opens that exact link can
313
+ * claim it. Navigate to it verbatim.
314
+ */
253
315
  pair_url: string;
254
316
  expires_in: number;
317
+ /**
318
+ * The surface the provider bound, echoed back. 'legacy' means the request
319
+ * sent no `display` (an older version of this package) and got the static
320
+ * code. Absent from a provider that predates the field, which also serves
321
+ * the static code.
322
+ */
323
+ display?: PairDisplay | 'legacy';
324
+ /** 'qr' pairings only: how often to re-fetch qr.svg. Defaults to 3 when absent. */
325
+ qr_refresh_seconds?: number;
255
326
  }
256
327
  interface PairImmediate {
257
328
  /** prompt=none resolved silently: consented sector, live session. */
@@ -263,7 +334,12 @@ interface PairStatusResponse {
263
334
  code?: string;
264
335
  expires_in?: number;
265
336
  enrolment_deadline?: number;
266
- /** The provider's reason on denial or refusal. Surfaced verbatim, never rewritten. */
337
+ /**
338
+ * The provider's reason on denial or refusal. Surfaced verbatim, never
339
+ * rewritten: it is also how a site's own policy reaches the person, such
340
+ * as a sign-in refused because the phone that approved it was in a
341
+ * different country than the browser.
342
+ */
267
343
  error?: string;
268
344
  error_description?: string;
269
345
  }
@@ -307,6 +383,13 @@ interface StartPairingParams {
307
383
  max_age?: number;
308
384
  prompt?: string;
309
385
  locale?: string;
386
+ /**
387
+ * Which surface the caller will show: 'qr' binds the pairing to moving QR
388
+ * frames, 'link' to a start token only the opened link carries. Decided
389
+ * before the request, because the provider binds it at creation and will
390
+ * not serve the other surface afterwards. Omitting it gets the static code.
391
+ */
392
+ display?: PairDisplay;
310
393
  }
311
394
  declare function startPairing(issuer: string, params: StartPairingParams, signal?: AbortSignal): Promise<PairStartResponse>;
312
395
  /**
@@ -351,4 +434,4 @@ declare function generateState(): string;
351
434
  */
352
435
  declare function unsafeClaims(idToken: string): Record<string, unknown>;
353
436
 
354
- export { type AcrValue, type AuthCodeLoginOptions, type BrowserDirectLoginOptions, DEFAULT_ISSUER, DEFAULT_PAIRING_TIMEOUT_MS, type ErrorCode, FlowAbandonedError, type LoginHandle, type NonOAuthError, OAuthFlowError, POLL_INTERVAL_ENROLLING_MS, POLL_INTERVAL_MS, type PairCreated, type PairImmediate, type PairStartResponse, type PairStatusResponse, type PairingModalHandle, type PairingModalOptions, type PairingState, type PairingUI, SDK_NAME, SDK_VERSION, type SelectBy, type StartLoginOptions, type StartPairingParams, type TokenResponse, WIRE_VERSION, type ZorealCodeResponse, type ZorealCredentialResponse, type ZorealTheme, challengeS256, exchangeCode, generateState, generateVerifier, isMobileUserAgent, mountPairingModal, pollUntilApproved, startLogin, startPairing, unsafeClaims };
437
+ export { type AcrValue, type AuthCodeLoginOptions, type BrowserDirectLoginOptions, DEFAULT_ISSUER, DEFAULT_PAIRING_TIMEOUT_MS, DEFAULT_QR_REFRESH_SECONDS, type ErrorCode, FlowAbandonedError, type LoginHandle, type NonOAuthError, OAuthFlowError, POLL_INTERVAL_ENROLLING_MS, POLL_INTERVAL_MS, type PairCreated, type PairDisplay, type PairImmediate, type PairStartResponse, type PairStatusResponse, type PairingModalHandle, type PairingModalOptions, type PairingState, type PairingUI, SDK_NAME, SDK_VERSION, type SelectBy, type StartLoginOptions, type StartPairingParams, type TokenResponse, WIRE_VERSION, type ZorealCodeResponse, type ZorealCredentialResponse, type ZorealTheme, challengeS256, exchangeCode, generateState, generateVerifier, isMobileUserAgent, mountPairingModal, pollUntilApproved, startLogin, startPairing, unsafeClaims };
package/dist/index.d.ts CHANGED
@@ -31,11 +31,28 @@ interface PairingState {
31
31
  /**
32
32
  * The pairing link and its provider-served QR image. Present on every
33
33
  * callback of a QR/link flow: the QR flow cannot complete unless SOMETHING
34
- * renders pairUrl, and in this package that something is always the caller.
34
+ * renders the code. By default that something is this package's own modal;
35
+ * these fields are what you render from instead when you opt out with
36
+ * `pairingUI: 'none'`.
35
37
  */
36
38
  pairUrl?: string;
37
- /** The provider-served SVG of pairUrl. Put it in an <img>; do not draw your own. */
39
+ /**
40
+ * The provider-served SVG of the code to scan. Put it in an <img>; do not
41
+ * draw your own. It CHANGES OVER TIME: while the pairing is pending a fresh
42
+ * URL arrives every `qrRefreshSeconds`, because the code on screen moves
43
+ * and the provider refuses a frame older than 30 seconds. That is what
44
+ * makes a screenshot of it useless to an attacker. Render the qrUrl you
45
+ * are given on every state; a UI that caches the first one shows a code
46
+ * that stops working.
47
+ */
38
48
  qrUrl?: string;
49
+ /**
50
+ * How often qrUrl changes, in seconds, while the pairing is pending.
51
+ * Present on a QR flow, absent on an app-link flow and on a provider that
52
+ * still serves a static code. Informational: the refresh itself is done for
53
+ * you and arrives through this callback.
54
+ */
55
+ qrRefreshSeconds?: number;
39
56
  /** True when the flow resolved to the app link (mobile) rather than a QR. */
40
57
  appLink?: boolean;
41
58
  /** Abandons this pairing: stops the poll. Wire it to your UI's cancel control. */
@@ -153,9 +170,13 @@ interface LoginHandle<T> {
153
170
  cancel: () => void;
154
171
  /** The pairing request id, once created. Undefined before, and for prompt=none immediate codes. */
155
172
  readonly requestId: string | undefined;
156
- /** The pairing URL, once created. The same URL in QR and app link. */
173
+ /** The pairing URL, once created. On an app-link flow it carries the start token; navigate to it verbatim. */
157
174
  readonly pairUrl: string | undefined;
158
- /** The provider-served QR image of pairUrl. */
175
+ /**
176
+ * The provider-served QR image, current frame. Changes every few seconds
177
+ * while the pairing is pending; the onState callback is where a UI should
178
+ * read it from, since that is the only place a new frame announces itself.
179
+ */
159
180
  readonly qrUrl: string | undefined;
160
181
  /** True when display resolved to the app link rather than the QR. */
161
182
  readonly appLink: boolean | undefined;
@@ -222,36 +243,86 @@ declare function mountPairingModal(state: PairingState, options: PairingModalOpt
222
243
  * authorized JavaScript origins (the dashboard):
223
244
  *
224
245
  * POST /pair start a pairing request. Body carries the
225
- * authorize parameters plus PKCE challenge.
226
- * Returns { request_id, pair_url, expires_in }
227
- * or, for prompt=none with a live consented
228
- * session, { code } immediately.
246
+ * authorize parameters plus PKCE challenge,
247
+ * and `display`: which pairing surface this
248
+ * package is about to show, 'qr' or 'link',
249
+ * decided before the request is made. Returns
250
+ * { request_id, pair_url, expires_in, display,
251
+ * qr_refresh_seconds } or, for prompt=none
252
+ * with a live consented session, { code }
253
+ * immediately. `display` is echoed as the
254
+ * provider bound it ('legacy' for a request
255
+ * that sent none, which gets the static code
256
+ * older versions of this package showed).
257
+ * `qr_refresh_seconds` comes with 'qr' and is
258
+ * how often to re-fetch the image; 3 today.
259
+ * A 'link' pairing's pair_url carries
260
+ * ?t=<start_token>: it can only be claimed by
261
+ * the app that opened that exact link, and
262
+ * the provider never renders a QR for it, so
263
+ * nobody can turn a same-device link into a
264
+ * static code to relay.
229
265
  * GET /pair/:id/status poll: pending | claimed |
230
266
  * approved (with code) | denied | expired |
231
267
  * enrolling. Over-polling cancels the request
232
268
  * rather than throttling it, so the cadence
233
269
  * below is not a suggestion.
234
- * GET /pair/:id/qr.svg the QR image for the pairing URL, served by
235
- * the provider so the pairing surface stays
236
- * changeable at runtime and
237
- * this package keeps zero dependencies.
270
+ * GET /pair/:id/qr.svg the QR image, rendered by the provider so
271
+ * this package draws nothing and keeps zero
272
+ * dependencies. For a 'qr' pairing it encodes
273
+ * the CURRENT FRAME, the pairing URL with
274
+ * ?f=<time>.<hmac>: `time` is whole seconds
275
+ * since the pairing was created on the
276
+ * provider's clock, `hmac` is keyed with a
277
+ * secret only the provider holds. Served
278
+ * Cache-Control: no-store, only while the
279
+ * pairing is pending. The app sends the frame
280
+ * it scanned with its claim, and the provider
281
+ * refuses a frame older than 30 seconds, so a
282
+ * screenshot of the code is dead on arrival.
283
+ * This package re-fetches the image every
284
+ * `qr_refresh_seconds` with a cache-busting
285
+ * ?t=<Date.now()>. A 'link' pairing has no
286
+ * image (404).
238
287
  * POST /token the code exchange. Browser-direct mode uses
239
288
  * it directly with PKCE and no client secret;
240
289
  * auth-code mode leaves it to the RP backend.
241
290
  */
242
291
  declare const WIRE_VERSION = 1;
243
- declare const SDK_VERSION = "0.1.8";
292
+ declare const SDK_VERSION = "0.1.10";
244
293
  declare const SDK_NAME = "@zoreal/oauth2-js";
245
294
  declare const DEFAULT_ISSUER = "https://id.zoreal.com";
246
295
  /** Pending TTL is short. Poll gently; over-polling cancels the request. */
247
296
  declare const POLL_INTERVAL_MS = 2000;
248
297
  /** Enrolling extends the window well beyond a normal login; poll slower. */
249
298
  declare const POLL_INTERVAL_ENROLLING_MS = 5000;
299
+ /**
300
+ * How often the QR image is re-fetched while a pairing is pending, when the
301
+ * provider does not say. The provider's own `qr_refresh_seconds` wins when
302
+ * present. Refreshing is what makes the code on screen move, and a frame the
303
+ * provider refuses after 30 seconds is what makes a screenshot of it useless.
304
+ */
305
+ declare const DEFAULT_QR_REFRESH_SECONDS = 3;
306
+ /** The pairing surface this package will show, sent on POST /pair. */
307
+ type PairDisplay = 'qr' | 'link';
250
308
  interface PairCreated {
251
309
  request_id: string;
252
- /** https://zoreal.com/qr/<request_id>. The same URL in QR and app link. */
310
+ /**
311
+ * https://zoreal.com/login/<request_id>. For a 'link' pairing the URL also
312
+ * carries ?t=<start_token>, and only the app that opens that exact link can
313
+ * claim it. Navigate to it verbatim.
314
+ */
253
315
  pair_url: string;
254
316
  expires_in: number;
317
+ /**
318
+ * The surface the provider bound, echoed back. 'legacy' means the request
319
+ * sent no `display` (an older version of this package) and got the static
320
+ * code. Absent from a provider that predates the field, which also serves
321
+ * the static code.
322
+ */
323
+ display?: PairDisplay | 'legacy';
324
+ /** 'qr' pairings only: how often to re-fetch qr.svg. Defaults to 3 when absent. */
325
+ qr_refresh_seconds?: number;
255
326
  }
256
327
  interface PairImmediate {
257
328
  /** prompt=none resolved silently: consented sector, live session. */
@@ -263,7 +334,12 @@ interface PairStatusResponse {
263
334
  code?: string;
264
335
  expires_in?: number;
265
336
  enrolment_deadline?: number;
266
- /** The provider's reason on denial or refusal. Surfaced verbatim, never rewritten. */
337
+ /**
338
+ * The provider's reason on denial or refusal. Surfaced verbatim, never
339
+ * rewritten: it is also how a site's own policy reaches the person, such
340
+ * as a sign-in refused because the phone that approved it was in a
341
+ * different country than the browser.
342
+ */
267
343
  error?: string;
268
344
  error_description?: string;
269
345
  }
@@ -307,6 +383,13 @@ interface StartPairingParams {
307
383
  max_age?: number;
308
384
  prompt?: string;
309
385
  locale?: string;
386
+ /**
387
+ * Which surface the caller will show: 'qr' binds the pairing to moving QR
388
+ * frames, 'link' to a start token only the opened link carries. Decided
389
+ * before the request, because the provider binds it at creation and will
390
+ * not serve the other surface afterwards. Omitting it gets the static code.
391
+ */
392
+ display?: PairDisplay;
310
393
  }
311
394
  declare function startPairing(issuer: string, params: StartPairingParams, signal?: AbortSignal): Promise<PairStartResponse>;
312
395
  /**
@@ -351,4 +434,4 @@ declare function generateState(): string;
351
434
  */
352
435
  declare function unsafeClaims(idToken: string): Record<string, unknown>;
353
436
 
354
- export { type AcrValue, type AuthCodeLoginOptions, type BrowserDirectLoginOptions, DEFAULT_ISSUER, DEFAULT_PAIRING_TIMEOUT_MS, type ErrorCode, FlowAbandonedError, type LoginHandle, type NonOAuthError, OAuthFlowError, POLL_INTERVAL_ENROLLING_MS, POLL_INTERVAL_MS, type PairCreated, type PairImmediate, type PairStartResponse, type PairStatusResponse, type PairingModalHandle, type PairingModalOptions, type PairingState, type PairingUI, SDK_NAME, SDK_VERSION, type SelectBy, type StartLoginOptions, type StartPairingParams, type TokenResponse, WIRE_VERSION, type ZorealCodeResponse, type ZorealCredentialResponse, type ZorealTheme, challengeS256, exchangeCode, generateState, generateVerifier, isMobileUserAgent, mountPairingModal, pollUntilApproved, startLogin, startPairing, unsafeClaims };
437
+ export { type AcrValue, type AuthCodeLoginOptions, type BrowserDirectLoginOptions, DEFAULT_ISSUER, DEFAULT_PAIRING_TIMEOUT_MS, DEFAULT_QR_REFRESH_SECONDS, type ErrorCode, FlowAbandonedError, type LoginHandle, type NonOAuthError, OAuthFlowError, POLL_INTERVAL_ENROLLING_MS, POLL_INTERVAL_MS, type PairCreated, type PairDisplay, type PairImmediate, type PairStartResponse, type PairStatusResponse, type PairingModalHandle, type PairingModalOptions, type PairingState, type PairingUI, SDK_NAME, SDK_VERSION, type SelectBy, type StartLoginOptions, type StartPairingParams, type TokenResponse, WIRE_VERSION, type ZorealCodeResponse, type ZorealCredentialResponse, type ZorealTheme, challengeS256, exchangeCode, generateState, generateVerifier, isMobileUserAgent, mountPairingModal, pollUntilApproved, startLogin, startPairing, unsafeClaims };
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.8";
17
+ var SDK_VERSION = "0.1.10";
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 (; ; ) {
@@ -1261,6 +1266,29 @@ function mountPairingModal(state, options) {
1261
1266
  scrim.appendChild(card);
1262
1267
  const serverMs = typeof state.expiresIn === "number" ? state.expiresIn * 1e3 : Infinity;
1263
1268
  const deadline = Date.now() + Math.min(timeoutMs, serverMs);
1269
+ let shown = state.qrUrl;
1270
+ let loading = null;
1271
+ const spent = () => qr.dataset.spent === "true";
1272
+ const showFrame = (url) => {
1273
+ if (url === shown || spent()) return;
1274
+ if (!shown) {
1275
+ qr.src = url;
1276
+ shown = url;
1277
+ return;
1278
+ }
1279
+ const next = new Image();
1280
+ loading = next;
1281
+ next.onload = () => {
1282
+ if (loading !== next || spent()) return;
1283
+ loading = null;
1284
+ qr.src = url;
1285
+ shown = url;
1286
+ };
1287
+ next.onerror = () => {
1288
+ if (loading === next) loading = null;
1289
+ };
1290
+ next.src = url;
1291
+ };
1264
1292
  const paint = (s) => {
1265
1293
  const settled = s.status === "claimed" || s.status === "enrolling";
1266
1294
  title.textContent = settled ? t.titleApprove : t.title;
@@ -1268,7 +1296,7 @@ function mountPairingModal(state, options) {
1268
1296
  statusLabel.textContent = settled ? t.waitingApproval : t.waiting;
1269
1297
  qr.dataset.spent = String(settled);
1270
1298
  overlay.style.display = settled ? "" : "none";
1271
- if (s.qrUrl && qr.src !== s.qrUrl) qr.src = s.qrUrl;
1299
+ if (s.qrUrl) showFrame(s.qrUrl);
1272
1300
  };
1273
1301
  const tick = () => {
1274
1302
  const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1e3));
@@ -1285,6 +1313,7 @@ function mountPairingModal(state, options) {
1285
1313
  const close = () => {
1286
1314
  if (closed) return;
1287
1315
  closed = true;
1316
+ loading = null;
1288
1317
  window.clearInterval(interval);
1289
1318
  document.removeEventListener("keydown", onKey);
1290
1319
  document.body.style.overflow = previousOverflow;
@@ -1334,10 +1363,15 @@ function startLogin(options) {
1334
1363
  const flow = options.flow ?? "browser-direct";
1335
1364
  const issuer = options.issuer ?? DEFAULT_ISSUER;
1336
1365
  const controller = new AbortController();
1366
+ const useAppLink = options.display === "link" || options.display !== "qr" && isMobileUserAgent();
1337
1367
  const surface = {};
1338
1368
  const cancel = () => controller.abort();
1339
1369
  let modal = null;
1340
- const closeModal = () => {
1370
+ let stopRefresh = () => {
1371
+ };
1372
+ controller.signal.addEventListener("abort", () => stopRefresh());
1373
+ const teardown = () => {
1374
+ stopRefresh();
1341
1375
  modal?.close();
1342
1376
  modal = null;
1343
1377
  };
@@ -1358,7 +1392,8 @@ function startLogin(options) {
1358
1392
  acr_values: Array.isArray(options.acr_values) ? options.acr_values.join(" ") : options.acr_values,
1359
1393
  max_age: options.max_age,
1360
1394
  prompt: options.prompt,
1361
- locale: options.locale
1395
+ locale: options.locale,
1396
+ display: useAppLink ? "link" : "qr"
1362
1397
  },
1363
1398
  controller.signal
1364
1399
  );
@@ -1368,19 +1403,25 @@ function startLogin(options) {
1368
1403
  code = started.code;
1369
1404
  selectBy = "session";
1370
1405
  } else {
1371
- const useAppLink = options.display === "link" || options.display !== "qr" && isMobileUserAgent();
1372
1406
  selectBy = useAppLink ? "app_link" : "qr";
1373
- surface.requestId = started.request_id;
1407
+ const requestId = started.request_id;
1408
+ const qrBase = `${issuer}/pair/${encodeURIComponent(requestId)}/qr.svg`;
1409
+ const animated = !useAppLink && started.display !== "legacy";
1410
+ const qrRefreshSeconds = !animated ? void 0 : typeof started.qr_refresh_seconds === "number" && started.qr_refresh_seconds > 0 ? started.qr_refresh_seconds : DEFAULT_QR_REFRESH_SECONDS;
1411
+ surface.requestId = requestId;
1374
1412
  surface.pairUrl = started.pair_url;
1375
- surface.qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;
1413
+ surface.qrUrl = qrBase;
1376
1414
  surface.appLink = useAppLink;
1377
- const stateSurface = {
1415
+ const withSurface = (s) => ({
1416
+ ...s,
1378
1417
  pairUrl: surface.pairUrl,
1379
1418
  qrUrl: surface.qrUrl,
1419
+ qrRefreshSeconds,
1380
1420
  appLink: useAppLink,
1381
1421
  cancel
1382
- };
1383
- const initial = { status: "pending", expiresIn: started.expires_in, ...stateSurface };
1422
+ });
1423
+ let lastPolled = { status: "pending", expiresIn: started.expires_in };
1424
+ const initial = withSurface(lastPolled);
1384
1425
  options.onState?.(initial);
1385
1426
  if ((options.pairingUI ?? "modal") === "modal" && !useAppLink) {
1386
1427
  modal = mountPairingModal(initial, {
@@ -1393,18 +1434,53 @@ function startLogin(options) {
1393
1434
  if (useAppLink && typeof window !== "undefined") {
1394
1435
  window.location.assign(started.pair_url);
1395
1436
  }
1437
+ if (qrRefreshSeconds !== void 0) {
1438
+ const periodMs = qrRefreshSeconds * 1e3;
1439
+ let due = Date.now() + periodMs;
1440
+ let timer;
1441
+ let stopped = false;
1442
+ const emit = () => {
1443
+ timer = void 0;
1444
+ surface.qrUrl = `${qrBase}?t=${Date.now()}`;
1445
+ const next = withSurface(lastPolled);
1446
+ modal?.update(next);
1447
+ options.onState?.(next);
1448
+ if (stopped) return;
1449
+ due = Date.now() + periodMs;
1450
+ timer = setTimeout(emit, periodMs);
1451
+ };
1452
+ const onVisible = () => {
1453
+ if (document.visibilityState === "visible" && timer !== void 0 && Date.now() >= due) {
1454
+ clearTimeout(timer);
1455
+ emit();
1456
+ }
1457
+ };
1458
+ const hasDocument = typeof document !== "undefined";
1459
+ if (hasDocument) document.addEventListener("visibilitychange", onVisible);
1460
+ stopRefresh = () => {
1461
+ stopped = true;
1462
+ if (timer !== void 0) clearTimeout(timer);
1463
+ timer = void 0;
1464
+ if (hasDocument) document.removeEventListener("visibilitychange", onVisible);
1465
+ stopRefresh = () => {
1466
+ };
1467
+ };
1468
+ timer = setTimeout(emit, Math.max(0, due - Date.now()));
1469
+ }
1396
1470
  code = await pollUntilApproved(
1397
1471
  issuer,
1398
- started.request_id,
1472
+ requestId,
1399
1473
  (s) => {
1400
- const next = { ...s, ...stateSurface };
1474
+ lastPolled = s;
1475
+ if (s.status !== "pending") stopRefresh();
1476
+ const next = withSurface(s);
1401
1477
  modal?.update(next);
1402
1478
  options.onState?.(next);
1403
1479
  },
1404
1480
  controller.signal
1405
1481
  );
1406
1482
  }
1407
- closeModal();
1483
+ teardown();
1408
1484
  if (flow === "auth-code") {
1409
1485
  const response2 = {
1410
1486
  code,
@@ -1429,7 +1505,7 @@ function startLogin(options) {
1429
1505
  };
1430
1506
  return response;
1431
1507
  } catch (e) {
1432
- closeModal();
1508
+ teardown();
1433
1509
  if (e instanceof DOMException && e.name === "AbortError") throw e;
1434
1510
  if (e instanceof OAuthFlowError || e instanceof FlowAbandonedError) throw e;
1435
1511
  throw new FlowAbandonedError({
@@ -1461,6 +1537,7 @@ function startLogin(options) {
1461
1537
  export {
1462
1538
  DEFAULT_ISSUER,
1463
1539
  DEFAULT_PAIRING_TIMEOUT_MS,
1540
+ DEFAULT_QR_REFRESH_SECONDS,
1464
1541
  FlowAbandonedError,
1465
1542
  OAuthFlowError,
1466
1543
  POLL_INTERVAL_ENROLLING_MS,