@zoreal/oauth2-js 0.1.18 → 0.1.20

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
@@ -140,7 +140,7 @@ is nothing to configure and nothing to draw.
140
140
 
141
141
  | | |
142
142
  | --- | --- |
143
- | **Mobile** | No QR and no modal. The tap itself is a navigation: `startLogin` sends the tab to the provider's `/pair/start` with the pairing's parameters, synchronously, and the provider answers with a redirect to the pairing's universal link, which the ZOREAL ID app claims while the page stays put and polls. A browser hands a link to an app only inside a navigation the person began, which is why nothing is fetched first. With no app installed the same redirect lands on the page that installs it. Call `startLogin` from the click handler itself, disable the button, and show it working until the promise settles. 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. |
143
+ | **Mobile** | No QR and no modal. The tap itself is a navigation: `startLogin` sends the tab to the provider's `/pair/start` with the pairing's parameters, synchronously, and the provider answers with a redirect to the pairing's universal link, which the ZOREAL ID app claims while the page stays put and polls. A browser hands a link to an app only inside a navigation the person began, which is why nothing is fetched first. With no app installed the same redirect lands on the page that installs it. Call `startLogin` from the click handler itself, disable the button, and show it working until the promise settles. Once the holder has approved, the app reopens your page with the pairing named in the fragment; call `resumeLogin({ clientId })` on every page load where `startLogin` can be called, and it finishes the sign-in there, resolving the way `startLogin` would have, or answers `null` at once when the page load is not a return. The tab that was left behind keeps polling and stands down when the returned page finishes first. 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. |
144
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). |
145
145
  | **Title** | Says what the scan is for, inferred from the request: "Scan to sign in" for `openid`, `email` and `profile.name`; "Scan to verify your identity" once a document attribute such as `zoreal.age` or `profile.birthdate` is requested; "Scan to prove you are a real human" for `openid` alone with `acr_values: 'zoreal.live'`. Override with `intent`, one of `'sign-in'`, `'identify'`, `'presence'`, when the scope does not say. |
146
146
  | **Countdown** | Counts down to expiry, turning amber under 20s. Reads the clock each tick rather than decrementing, so a backgrounded tab comes back honest. |
package/dist/index.cjs CHANGED
@@ -38,6 +38,7 @@ __export(index_exports, {
38
38
  mountPairingModal: () => mountPairingModal,
39
39
  pollUntilApproved: () => pollUntilApproved,
40
40
  resolveIntent: () => resolveIntent,
41
+ resumeLogin: () => resumeLogin,
41
42
  startLogin: () => startLogin,
42
43
  startPairing: () => startPairing,
43
44
  unsafeClaims: () => unsafeClaims
@@ -60,7 +61,7 @@ function unsafeClaims(idToken) {
60
61
 
61
62
  // src/wire.ts
62
63
  var WIRE_VERSION = 1;
63
- var SDK_VERSION = "0.1.18";
64
+ var SDK_VERSION = "0.1.20";
64
65
  var SDK_NAME = "@zoreal/oauth2-js";
65
66
  var DEFAULT_ISSUER = "https://id.zoreal.com";
66
67
  var POLL_INTERVAL_MS = 2e3;
@@ -138,11 +139,30 @@ var sleep = (ms, signal) => new Promise((resolve, reject) => {
138
139
  }, ms);
139
140
  signal?.addEventListener("abort", onAbort, { once: true });
140
141
  });
142
+ var SETTLE_MS = 1500;
143
+ var NETWORK_FAILURES_TOLERATED = 4;
141
144
  async function pollUntilApproved(issuer, requestId, onState, signal, options = {}) {
145
+ const settlingUntil = options.tolerateUnknownUntil ?? 0;
146
+ let networkFailures = 0;
147
+ let last = { status: "pending" };
148
+ if (settlingUntil > Date.now()) await sleep(SETTLE_MS, signal);
142
149
  for (; ; ) {
143
- const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
144
- signal
145
- });
150
+ let response;
151
+ try {
152
+ response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
153
+ signal
154
+ });
155
+ networkFailures = 0;
156
+ } catch (e) {
157
+ if (e instanceof DOMException && e.name === "AbortError") throw e;
158
+ networkFailures += 1;
159
+ if (settlingUntil > Date.now() || networkFailures <= NETWORK_FAILURES_TOLERATED) {
160
+ onState?.(last);
161
+ await sleep(POLL_INTERVAL_MS, signal);
162
+ continue;
163
+ }
164
+ throw e;
165
+ }
146
166
  const body = await parseJson(response);
147
167
  if (response.status === 404 && (options.tolerateUnknownUntil ?? 0) > Date.now()) {
148
168
  onState?.({ status: "pending" });
@@ -155,11 +175,12 @@ async function pollUntilApproved(issuer, requestId, onState, signal, options = {
155
175
  body.error_description ?? `Pairing status failed (${response.status})`
156
176
  );
157
177
  }
158
- onState?.({
178
+ last = {
159
179
  status: body.status,
160
180
  expiresIn: body.expires_in,
161
181
  enrolmentDeadline: body.enrolment_deadline
162
- });
182
+ };
183
+ onState?.(last);
163
184
  switch (body.status) {
164
185
  case "approved":
165
186
  if (!body.code) {
@@ -224,6 +245,76 @@ function titleFor(t, intent) {
224
245
  return t.title;
225
246
  }
226
247
 
248
+ // src/return.ts
249
+ var PREFIX = "zoreal:oauth2:return:";
250
+ var DONE = "zoreal:oauth2:done:";
251
+ var MAX_AGE_MS = 10 * 60 * 1e3;
252
+ function storage() {
253
+ try {
254
+ return typeof localStorage === "undefined" ? null : localStorage;
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+ function saveReturnFlow(flow) {
260
+ try {
261
+ storage()?.setItem(PREFIX + flow.requestId, JSON.stringify(flow));
262
+ } catch {
263
+ }
264
+ }
265
+ function peekReturnFlow(requestId) {
266
+ const store = storage();
267
+ if (!store) return null;
268
+ const raw = store.getItem(PREFIX + requestId);
269
+ if (!raw) return null;
270
+ try {
271
+ const flow = JSON.parse(raw);
272
+ if (flow.v !== 1 || flow.requestId !== requestId) return null;
273
+ if (Date.now() - flow.createdAt > MAX_AGE_MS) return null;
274
+ return flow;
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
279
+ function forgetReturnFlow(requestId) {
280
+ try {
281
+ storage()?.removeItem(PREFIX + requestId);
282
+ } catch {
283
+ }
284
+ if (pending === requestId) pending = null;
285
+ }
286
+ function markReturnDone(requestId) {
287
+ try {
288
+ const store = storage();
289
+ store?.setItem(DONE + requestId, String(Date.now()));
290
+ store?.removeItem(PREFIX + requestId);
291
+ } catch {
292
+ }
293
+ }
294
+ function isReturnDone(requestId) {
295
+ return storage()?.getItem(DONE + requestId) !== null && storage()?.getItem(DONE + requestId) !== void 0;
296
+ }
297
+ function returnToUrl() {
298
+ if (typeof window === "undefined") return void 0;
299
+ const { href } = window.location;
300
+ const hash = href.indexOf("#");
301
+ return hash === -1 ? href : href.slice(0, hash);
302
+ }
303
+ var RETURN_MARK = /(?:^|[#&])zoreal_return=([A-Za-z0-9]{32})(?:&|$)/;
304
+ var pending = null;
305
+ function pendingReturnId() {
306
+ if (pending) return pending;
307
+ if (typeof window === "undefined") return null;
308
+ const match = RETURN_MARK.exec(window.location.hash);
309
+ if (!match) return null;
310
+ pending = match[1];
311
+ try {
312
+ window.history.replaceState(window.history.state, "", returnToUrl());
313
+ } catch {
314
+ }
315
+ return pending;
316
+ }
317
+
227
318
  // src/i18n.ts
228
319
  var en = {
229
320
  title: "Scan to sign in",
@@ -1067,8 +1158,8 @@ function interpolate(template, time) {
1067
1158
  }
1068
1159
 
1069
1160
  // src/styles.ts
1070
- var PREFIX = "zrl";
1071
- var cx = (name) => `${PREFIX}-${name}`;
1161
+ var PREFIX2 = "zrl";
1162
+ var cx = (name) => `${PREFIX2}-${name}`;
1072
1163
  var STYLE_ELEMENT_ID = "zoreal-pairing-styles";
1073
1164
  var LIGHT = `
1074
1165
  --zrl-scrim: rgba(16, 18, 27, 0.45);
@@ -1132,13 +1223,13 @@ var DARK = `
1132
1223
  --zrl-glow-opacity: 0.85;
1133
1224
  `;
1134
1225
  var CSS = `
1135
- .${PREFIX}-root { ${LIGHT} }
1136
- .${PREFIX}-root[data-theme="dark"] { ${DARK} }
1226
+ .${PREFIX2}-root { ${LIGHT} }
1227
+ .${PREFIX2}-root[data-theme="dark"] { ${DARK} }
1137
1228
  @media (prefers-color-scheme: dark) {
1138
- .${PREFIX}-root[data-theme="auto"] { ${DARK} }
1229
+ .${PREFIX2}-root[data-theme="auto"] { ${DARK} }
1139
1230
  }
1140
1231
 
1141
- .${PREFIX}-scrim {
1232
+ .${PREFIX2}-scrim {
1142
1233
  position: fixed;
1143
1234
  inset: 0;
1144
1235
  z-index: 2147483000;
@@ -1150,10 +1241,10 @@ var CSS = `
1150
1241
  backdrop-filter: blur(3px);
1151
1242
  -webkit-backdrop-filter: blur(3px);
1152
1243
  font-family: inherit;
1153
- animation: ${PREFIX}-fade 200ms ease-out both;
1244
+ animation: ${PREFIX2}-fade 200ms ease-out both;
1154
1245
  }
1155
1246
 
1156
- .${PREFIX}-card {
1247
+ .${PREFIX2}-card {
1157
1248
  position: relative;
1158
1249
  box-sizing: border-box;
1159
1250
  width: 100%;
@@ -1165,14 +1256,14 @@ var CSS = `
1165
1256
  outline: 1px solid var(--zrl-ring);
1166
1257
  outline-offset: -1px;
1167
1258
  text-align: center;
1168
- animation: ${PREFIX}-rise 300ms cubic-bezier(0.23, 1, 0.32, 1) both;
1259
+ animation: ${PREFIX2}-rise 300ms cubic-bezier(0.23, 1, 0.32, 1) both;
1169
1260
  }
1170
1261
 
1171
- .${PREFIX}-body { padding: 28px 24px 20px; }
1262
+ .${PREFIX2}-body { padding: 28px 24px 20px; }
1172
1263
 
1173
- .${PREFIX}-lockup { display: block; margin: 0 auto; color: var(--zrl-ink); }
1264
+ .${PREFIX2}-lockup { display: block; margin: 0 auto; color: var(--zrl-ink); }
1174
1265
 
1175
- .${PREFIX}-title {
1266
+ .${PREFIX2}-title {
1176
1267
  margin: 18px 0 0;
1177
1268
  font-size: 18px;
1178
1269
  font-weight: 600;
@@ -1181,7 +1272,7 @@ var CSS = `
1181
1272
  color: var(--zrl-ink);
1182
1273
  }
1183
1274
 
1184
- .${PREFIX}-body-text {
1275
+ .${PREFIX2}-body-text {
1185
1276
  margin: 6px auto 0;
1186
1277
  max-width: 30ch;
1187
1278
  font-size: 14px;
@@ -1189,7 +1280,7 @@ var CSS = `
1189
1280
  color: var(--zrl-ink-soft);
1190
1281
  }
1191
1282
 
1192
- .${PREFIX}-qr-well {
1283
+ .${PREFIX2}-qr-well {
1193
1284
  position: relative;
1194
1285
  display: grid;
1195
1286
  place-items: center;
@@ -1228,9 +1319,9 @@ var CSS = `
1228
1319
  All three share one containing block, the well's padding box, so the two
1229
1320
  comets ride the same path; the spent badge is a later sibling and paints
1230
1321
  above them. */
1231
- .${PREFIX}-qr-beam,
1232
- .${PREFIX}-qr-beam-glow,
1233
- .${PREFIX}-qr-beam-glow-band {
1322
+ .${PREFIX2}-qr-beam,
1323
+ .${PREFIX2}-qr-beam-glow,
1324
+ .${PREFIX2}-qr-beam-glow-band {
1234
1325
  position: absolute;
1235
1326
  inset: calc(0px - var(--zrl-beam-line));
1236
1327
  border: var(--zrl-beam-line) solid transparent;
@@ -1243,7 +1334,7 @@ var CSS = `
1243
1334
  mask-clip: padding-box, border-box;
1244
1335
  mask-composite: intersect;
1245
1336
  }
1246
- .${PREFIX}-qr-beam-glow {
1337
+ .${PREFIX2}-qr-beam-glow {
1247
1338
  inset: calc(0px - var(--zrl-glow-reach));
1248
1339
  border-width: var(--zrl-glow-reach);
1249
1340
  border-radius: calc(var(--zrl-radius) - 1px + var(--zrl-glow-reach));
@@ -1251,7 +1342,7 @@ var CSS = `
1251
1342
  opacity: var(--zrl-glow-opacity);
1252
1343
  will-change: filter;
1253
1344
  }
1254
- .${PREFIX}-qr-beam-glow-band {
1345
+ .${PREFIX2}-qr-beam-glow-band {
1255
1346
  inset: calc(0px - var(--zrl-glow-core));
1256
1347
  border-width: var(--zrl-glow-core);
1257
1348
  border-radius: calc(var(--zrl-radius) - 1px + var(--zrl-glow-core));
@@ -1262,8 +1353,8 @@ var CSS = `
1262
1353
  border reads as the well's own line with a light passing over it; shown
1263
1354
  once the light has stopped. Every path below ends here, which is what
1264
1355
  makes them look the same at rest. */
1265
- .${PREFIX}-qr-beam::before,
1266
- .${PREFIX}-qr-beam-glow-band::before {
1356
+ .${PREFIX2}-qr-beam::before,
1357
+ .${PREFIX2}-qr-beam-glow-band::before {
1267
1358
  content: '';
1268
1359
  position: absolute;
1269
1360
  inset: -50%;
@@ -1276,8 +1367,8 @@ var CSS = `
1276
1367
  whole. A transform animation runs on the compositor, so the light keeps
1277
1368
  moving while the page is busy; animating the gradient angle instead
1278
1369
  repaints every frame on the main thread and stutters. */
1279
- .${PREFIX}-qr-beam::after,
1280
- .${PREFIX}-qr-beam-glow-band::after {
1370
+ .${PREFIX2}-qr-beam::after,
1371
+ .${PREFIX2}-qr-beam-glow-band::after {
1281
1372
  content: '';
1282
1373
  position: absolute;
1283
1374
  inset: -50%;
@@ -1288,7 +1379,7 @@ var CSS = `
1288
1379
  var(--zrl-beam-head) 348deg,
1289
1380
  transparent 356deg 360deg
1290
1381
  );
1291
- animation: ${PREFIX}-orbit var(--zrl-beam-time) linear infinite;
1382
+ animation: ${PREFIX2}-orbit var(--zrl-beam-time) linear infinite;
1292
1383
  will-change: transform;
1293
1384
  transition: opacity 400ms cubic-bezier(0.23, 1, 0.32, 1);
1294
1385
  }
@@ -1296,15 +1387,15 @@ var CSS = `
1296
1387
  /* Spent: the light stops where it is and fades, and the edge settles to the
1297
1388
  dim glow. Paused rather than removed, so it does not jump back to its start
1298
1389
  on the way out. */
1299
- .${PREFIX}-qr-well[data-spent="true"] .${PREFIX}-qr-beam::after,
1300
- .${PREFIX}-qr-well[data-spent="true"] .${PREFIX}-qr-beam-glow-band::after {
1390
+ .${PREFIX2}-qr-well[data-spent="true"] .${PREFIX2}-qr-beam::after,
1391
+ .${PREFIX2}-qr-well[data-spent="true"] .${PREFIX2}-qr-beam-glow-band::after {
1301
1392
  animation-play-state: paused;
1302
1393
  opacity: 0;
1303
1394
  }
1304
- .${PREFIX}-qr-well[data-spent="true"] .${PREFIX}-qr-beam::before { opacity: 0.55; }
1305
- .${PREFIX}-qr-well[data-spent="true"] .${PREFIX}-qr-beam-glow-band::before { opacity: 0.7; }
1395
+ .${PREFIX2}-qr-well[data-spent="true"] .${PREFIX2}-qr-beam::before { opacity: 0.55; }
1396
+ .${PREFIX2}-qr-well[data-spent="true"] .${PREFIX2}-qr-beam-glow-band::before { opacity: 0.7; }
1306
1397
 
1307
- .${PREFIX}-qr {
1398
+ .${PREFIX2}-qr {
1308
1399
  display: block;
1309
1400
  width: 100%;
1310
1401
  height: 100%;
@@ -1319,17 +1410,17 @@ var CSS = `
1319
1410
  /* Once the code is claimed the QR is spent. Blurring it out rather than
1320
1411
  swapping it keeps one object on screen through the state change, so the eye
1321
1412
  reads a transformation instead of two things trading places. */
1322
- .${PREFIX}-qr[data-spent="true"] { opacity: 0.2; filter: var(--zrl-qr-spent-filter); transform: scale(0.96); }
1413
+ .${PREFIX2}-qr[data-spent="true"] { opacity: 0.2; filter: var(--zrl-qr-spent-filter); transform: scale(0.96); }
1323
1414
 
1324
- .${PREFIX}-qr-overlay {
1415
+ .${PREFIX2}-qr-overlay {
1325
1416
  position: absolute;
1326
1417
  inset: 0;
1327
1418
  display: grid;
1328
1419
  place-items: center;
1329
- animation: ${PREFIX}-fade 200ms ease-out both;
1420
+ animation: ${PREFIX2}-fade 200ms ease-out both;
1330
1421
  }
1331
1422
 
1332
- .${PREFIX}-qr-badge {
1423
+ .${PREFIX2}-qr-badge {
1333
1424
  display: grid;
1334
1425
  place-items: center;
1335
1426
  width: 56px;
@@ -1339,7 +1430,7 @@ var CSS = `
1339
1430
  color: var(--zrl-accent-ink);
1340
1431
  }
1341
1432
 
1342
- .${PREFIX}-status {
1433
+ .${PREFIX2}-status {
1343
1434
  display: flex;
1344
1435
  align-items: center;
1345
1436
  justify-content: center;
@@ -1350,8 +1441,8 @@ var CSS = `
1350
1441
  color: var(--zrl-ink);
1351
1442
  }
1352
1443
 
1353
- .${PREFIX}-dot { position: relative; display: grid; place-items: center; width: 8px; height: 8px; }
1354
- .${PREFIX}-dot i {
1444
+ .${PREFIX2}-dot { position: relative; display: grid; place-items: center; width: 8px; height: 8px; }
1445
+ .${PREFIX2}-dot i {
1355
1446
  position: absolute;
1356
1447
  width: 8px;
1357
1448
  height: 8px;
@@ -1359,25 +1450,25 @@ var CSS = `
1359
1450
  background: var(--zrl-accent);
1360
1451
  font-style: normal;
1361
1452
  }
1362
- .${PREFIX}-dot i:first-child { animation: ${PREFIX}-ping 1.8s cubic-bezier(0.23, 1, 0.32, 1) infinite; }
1453
+ .${PREFIX2}-dot i:first-child { animation: ${PREFIX2}-ping 1.8s cubic-bezier(0.23, 1, 0.32, 1) infinite; }
1363
1454
 
1364
- .${PREFIX}-timer {
1455
+ .${PREFIX2}-timer {
1365
1456
  margin: 4px 0 0;
1366
1457
  font-size: 12px;
1367
1458
  font-variant-numeric: tabular-nums;
1368
1459
  color: var(--zrl-ink-mute);
1369
1460
  transition: color 200ms ease-out;
1370
1461
  }
1371
- .${PREFIX}-timer[data-urgent="true"] { color: var(--zrl-urgent); }
1462
+ .${PREFIX2}-timer[data-urgent="true"] { color: var(--zrl-urgent); }
1372
1463
 
1373
- .${PREFIX}-help {
1464
+ .${PREFIX2}-help {
1374
1465
  padding: 14px 24px;
1375
1466
  border-top: 1px solid var(--zrl-line-soft);
1376
1467
  background: var(--zrl-surface-sunken);
1377
1468
  border-radius: 0;
1378
1469
  }
1379
- .${PREFIX}-help-title { margin: 0; font-size: 12px; font-weight: 600; color: var(--zrl-ink); }
1380
- .${PREFIX}-help-body {
1470
+ .${PREFIX2}-help-title { margin: 0; font-size: 12px; font-weight: 600; color: var(--zrl-ink); }
1471
+ .${PREFIX2}-help-body {
1381
1472
  margin: 4px auto 0;
1382
1473
  max-width: 34ch;
1383
1474
  font-size: 12px;
@@ -1385,9 +1476,9 @@ var CSS = `
1385
1476
  color: var(--zrl-ink-soft);
1386
1477
  }
1387
1478
 
1388
- .${PREFIX}-footer { padding: 12px; border-top: 1px solid var(--zrl-line-soft); }
1479
+ .${PREFIX2}-footer { padding: 12px; border-top: 1px solid var(--zrl-line-soft); }
1389
1480
 
1390
- .${PREFIX}-cancel {
1481
+ .${PREFIX2}-cancel {
1391
1482
  display: block;
1392
1483
  width: 100%;
1393
1484
  padding: 10px;
@@ -1401,10 +1492,10 @@ var CSS = `
1401
1492
  cursor: pointer;
1402
1493
  transition: background-color 150ms ease-out, color 150ms ease-out, transform 150ms ease-out;
1403
1494
  }
1404
- .${PREFIX}-cancel:hover { background: var(--zrl-surface-sunken); color: var(--zrl-ink); }
1405
- .${PREFIX}-cancel:active { transform: scale(0.99); }
1495
+ .${PREFIX2}-cancel:hover { background: var(--zrl-surface-sunken); color: var(--zrl-ink); }
1496
+ .${PREFIX2}-cancel:active { transform: scale(0.99); }
1406
1497
 
1407
- .${PREFIX}-secured {
1498
+ .${PREFIX2}-secured {
1408
1499
  display: flex;
1409
1500
  align-items: center;
1410
1501
  justify-content: center;
@@ -1416,9 +1507,9 @@ var CSS = `
1416
1507
  border-radius: 6px;
1417
1508
  transition: color 150ms ease-out;
1418
1509
  }
1419
- .${PREFIX}-secured:hover { color: var(--zrl-ink); }
1510
+ .${PREFIX2}-secured:hover { color: var(--zrl-ink); }
1420
1511
 
1421
- .${PREFIX}-close {
1512
+ .${PREFIX2}-close {
1422
1513
  position: absolute;
1423
1514
  top: 12px;
1424
1515
  inset-inline-end: 12px;
@@ -1434,25 +1525,25 @@ var CSS = `
1434
1525
  cursor: pointer;
1435
1526
  transition: background-color 150ms ease-out, color 150ms ease-out, transform 150ms ease-out;
1436
1527
  }
1437
- .${PREFIX}-close:hover { background: var(--zrl-surface-sunken); color: var(--zrl-ink); }
1438
- .${PREFIX}-close:active { transform: scale(0.95); }
1528
+ .${PREFIX2}-close:hover { background: var(--zrl-surface-sunken); color: var(--zrl-ink); }
1529
+ .${PREFIX2}-close:active { transform: scale(0.95); }
1439
1530
 
1440
- .${PREFIX}-card :focus-visible {
1531
+ .${PREFIX2}-card :focus-visible {
1441
1532
  outline: 2px solid var(--zrl-accent);
1442
1533
  outline-offset: 2px;
1443
1534
  }
1444
1535
 
1445
- @keyframes ${PREFIX}-fade { from { opacity: 0 } to { opacity: 1 } }
1446
- @keyframes ${PREFIX}-rise {
1536
+ @keyframes ${PREFIX2}-fade { from { opacity: 0 } to { opacity: 1 } }
1537
+ @keyframes ${PREFIX2}-rise {
1447
1538
  from { opacity: 0; transform: translateY(10px) scale(0.98) }
1448
1539
  to { opacity: 1; transform: none }
1449
1540
  }
1450
- @keyframes ${PREFIX}-ping {
1541
+ @keyframes ${PREFIX2}-ping {
1451
1542
  0% { transform: scale(1); opacity: 0.5 }
1452
1543
  70%, 100% { transform: scale(2.6); opacity: 0 }
1453
1544
  }
1454
1545
 
1455
- @keyframes ${PREFIX}-orbit { to { transform: rotate(360deg) } }
1546
+ @keyframes ${PREFIX2}-orbit { to { transform: rotate(360deg) } }
1456
1547
  /* THE BUSY RING. The light of the QR well, around any control that is
1457
1548
  waiting on the provider: the button on a phone between the tap and the
1458
1549
  hand-over to the app. The well's sweep is a cone from the centre, which is
@@ -1469,13 +1560,13 @@ var CSS = `
1469
1560
  straight fade from the head to nothing three tenths of the way back; the
1470
1561
  component sets each layer's length, offset and opacity. Shown only while
1471
1562
  busy. */
1472
- .${PREFIX}-ring {
1563
+ .${PREFIX2}-ring {
1473
1564
  position: relative;
1474
1565
  display: inline-flex;
1475
1566
  vertical-align: middle;
1476
1567
  --zrl-beam-time: 4s;
1477
1568
  }
1478
- .${PREFIX}-ring-svg {
1569
+ .${PREFIX2}-ring-svg {
1479
1570
  position: absolute;
1480
1571
  inset: -4px;
1481
1572
  width: calc(100% + 8px);
@@ -1485,8 +1576,8 @@ var CSS = `
1485
1576
  opacity: 0;
1486
1577
  transition: opacity 200ms ease-out;
1487
1578
  }
1488
- .${PREFIX}-ring[data-busy="true"] > .${PREFIX}-ring-svg { opacity: 1; }
1489
- .${PREFIX}-ring-svg rect {
1579
+ .${PREFIX2}-ring[data-busy="true"] > .${PREFIX2}-ring-svg { opacity: 1; }
1580
+ .${PREFIX2}-ring-svg rect {
1490
1581
  --zrl-l: var(--zrl-ring-len, 600px);
1491
1582
  x: 2px;
1492
1583
  y: 2px;
@@ -1497,37 +1588,37 @@ var CSS = `
1497
1588
  stroke-width: var(--zrl-beam-line);
1498
1589
  stroke-linecap: round;
1499
1590
  stroke-dashoffset: var(--zrl-s, 0px);
1500
- animation: ${PREFIX}-dash var(--zrl-beam-time) linear infinite;
1591
+ animation: ${PREFIX2}-dash var(--zrl-beam-time) linear infinite;
1501
1592
  }
1502
- .${PREFIX}-ring-head { stroke: var(--zrl-beam-head); }
1503
- .${PREFIX}-ring-halo {
1593
+ .${PREFIX2}-ring-head { stroke: var(--zrl-beam-head); }
1594
+ .${PREFIX2}-ring-halo {
1504
1595
  stroke-width: calc(var(--zrl-glow-core) * 2 + var(--zrl-beam-line));
1505
1596
  filter: blur(var(--zrl-glow-blur));
1506
1597
  }
1507
- @keyframes ${PREFIX}-dash {
1598
+ @keyframes ${PREFIX2}-dash {
1508
1599
  from { stroke-dashoffset: var(--zrl-s, 0px); }
1509
1600
  to { stroke-dashoffset: calc(var(--zrl-s, 0px) - var(--zrl-l)); }
1510
1601
  }
1511
1602
  @media (prefers-reduced-motion: reduce) {
1512
- .${PREFIX}-ring-svg rect { animation: none; stroke-dasharray: none; opacity: 0.45; }
1513
- .${PREFIX}-ring-halo, .${PREFIX}-ring-head { display: none; }
1603
+ .${PREFIX2}-ring-svg rect { animation: none; stroke-dasharray: none; opacity: 0.45; }
1604
+ .${PREFIX2}-ring-halo, .${PREFIX2}-ring-head { display: none; }
1514
1605
  }
1515
1606
 
1516
1607
 
1517
1608
  @media (prefers-reduced-motion: reduce) {
1518
- .${PREFIX}-scrim,
1519
- .${PREFIX}-card,
1520
- .${PREFIX}-qr-overlay { animation: none }
1521
- .${PREFIX}-dot i:first-child { animation: none; opacity: 0.35 }
1522
- .${PREFIX}-qr,
1523
- .${PREFIX}-cancel,
1524
- .${PREFIX}-close,
1525
- .${PREFIX}-timer { transition: none }
1609
+ .${PREFIX2}-scrim,
1610
+ .${PREFIX2}-card,
1611
+ .${PREFIX2}-qr-overlay { animation: none }
1612
+ .${PREFIX2}-dot i:first-child { animation: none; opacity: 0.35 }
1613
+ .${PREFIX2}-qr,
1614
+ .${PREFIX2}-cancel,
1615
+ .${PREFIX2}-close,
1616
+ .${PREFIX2}-timer { transition: none }
1526
1617
  /* No travelling light; the edge keeps its dim static glow instead. */
1527
- .${PREFIX}-qr-beam::after,
1528
- .${PREFIX}-qr-beam-glow-band::after { animation: none; opacity: 0 }
1529
- .${PREFIX}-qr-beam::before { opacity: 0.55 }
1530
- .${PREFIX}-qr-beam-glow-band::before { opacity: 0.7 }
1618
+ .${PREFIX2}-qr-beam::after,
1619
+ .${PREFIX2}-qr-beam-glow-band::after { animation: none; opacity: 0 }
1620
+ .${PREFIX2}-qr-beam::before { opacity: 0.55 }
1621
+ .${PREFIX2}-qr-beam-glow-band::before { opacity: 0.7 }
1531
1622
  }
1532
1623
  `;
1533
1624
  function ensureStyles() {
@@ -1932,8 +2023,23 @@ function startLogin(options) {
1932
2023
  try {
1933
2024
  let code;
1934
2025
  let selectBy = "device";
2026
+ let returnId = null;
1935
2027
  if (useAppLink && typeof window !== "undefined") {
1936
2028
  const requestId = generateRequestId();
2029
+ saveReturnFlow({
2030
+ v: 1,
2031
+ issuer,
2032
+ clientId: options.clientId,
2033
+ flow,
2034
+ verifier,
2035
+ nonce,
2036
+ state,
2037
+ scope: options.scope ?? "openid",
2038
+ appState: options.app_state,
2039
+ requestId,
2040
+ createdAt: Date.now()
2041
+ });
2042
+ returnId = requestId;
1937
2043
  const startUrl = sameDeviceStartUrl(issuer, {
1938
2044
  client_id: options.clientId,
1939
2045
  scope: options.scope ?? "openid",
@@ -1946,7 +2052,8 @@ function startLogin(options) {
1946
2052
  prompt: options.prompt,
1947
2053
  locale: options.locale,
1948
2054
  request_id: requestId,
1949
- origin: window.location.origin
2055
+ origin: window.location.origin,
2056
+ return_to: returnToUrl()
1950
2057
  });
1951
2058
  selectBy = "app_link";
1952
2059
  surface.requestId = requestId;
@@ -1968,6 +2075,9 @@ function startLogin(options) {
1968
2075
  controller.signal,
1969
2076
  { tolerateUnknownUntil: Date.now() + 15e3 }
1970
2077
  );
2078
+ if (isReturnDone(requestId)) {
2079
+ throw new DOMException("aborted", "AbortError");
2080
+ }
1971
2081
  } else {
1972
2082
  const started = await startPairing(
1973
2083
  issuer,
@@ -2068,6 +2178,7 @@ function startLogin(options) {
2068
2178
  }
2069
2179
  teardown();
2070
2180
  }
2181
+ if (returnId) markReturnDone(returnId);
2071
2182
  if (flow === "auth-code") {
2072
2183
  const response2 = {
2073
2184
  code,
@@ -2121,6 +2232,67 @@ function startLogin(options) {
2121
2232
  }
2122
2233
  };
2123
2234
  }
2235
+ function resumeLogin(options) {
2236
+ const id = pendingReturnId();
2237
+ if (!id) return null;
2238
+ const saved = peekReturnFlow(id);
2239
+ if (!saved || saved.clientId !== options.clientId) return null;
2240
+ forgetReturnFlow(id);
2241
+ const issuer = options.issuer ?? saved.issuer;
2242
+ const controller = new AbortController();
2243
+ const promise = (async () => {
2244
+ const code = await pollUntilApproved(
2245
+ issuer,
2246
+ id,
2247
+ (s) => options.onState?.({ ...s, appLink: true }),
2248
+ controller.signal,
2249
+ { tolerateUnknownUntil: Date.now() + 5e3 }
2250
+ );
2251
+ let response;
2252
+ if (saved.flow === "auth-code") {
2253
+ response = {
2254
+ code,
2255
+ scope: saved.scope,
2256
+ app_state: saved.appState,
2257
+ code_verifier: saved.verifier,
2258
+ nonce: saved.nonce
2259
+ };
2260
+ } else {
2261
+ const tokens = await exchangeCode(issuer, {
2262
+ code,
2263
+ code_verifier: saved.verifier,
2264
+ client_id: options.clientId
2265
+ });
2266
+ const claims = unsafeClaims(tokens.id_token);
2267
+ response = {
2268
+ credential: tokens.id_token,
2269
+ clientId: options.clientId,
2270
+ select_by: "app_link",
2271
+ acr: claims.acr ?? "zoreal.device"
2272
+ };
2273
+ }
2274
+ markReturnDone(id);
2275
+ return response;
2276
+ })();
2277
+ promise.catch(() => {
2278
+ });
2279
+ return {
2280
+ promise,
2281
+ cancel: () => controller.abort(),
2282
+ get requestId() {
2283
+ return id;
2284
+ },
2285
+ get pairUrl() {
2286
+ return void 0;
2287
+ },
2288
+ get qrUrl() {
2289
+ return void 0;
2290
+ },
2291
+ get appLink() {
2292
+ return true;
2293
+ }
2294
+ };
2295
+ }
2124
2296
  // Annotate the CommonJS export names for ESM import in node:
2125
2297
  0 && (module.exports = {
2126
2298
  DEFAULT_ISSUER,
@@ -2141,6 +2313,7 @@ function startLogin(options) {
2141
2313
  mountPairingModal,
2142
2314
  pollUntilApproved,
2143
2315
  resolveIntent,
2316
+ resumeLogin,
2144
2317
  startLogin,
2145
2318
  startPairing,
2146
2319
  unsafeClaims