@zoreal/oauth2-react 0.2.8 → 0.2.9
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 +14 -3
- package/dist/index.cjs +129 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.js +129 -53
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -239,6 +239,14 @@ What the modal does:
|
|
|
239
239
|
| **Language** | Ships its own copy in 39 locales (listed below). Pass `locale` and the modal matches the page it opened on; omit it and it follows the browser's preference list, English as the floor. Arabic, Hebrew and Urdu flip the dialog to RTL. |
|
|
240
240
|
| **Accessibility** | `role="dialog"`, `aria-modal`, labelled by its title, focus moved in on open, scroll locked, visible focus rings, and a `prefers-reduced-motion` fallback. |
|
|
241
241
|
|
|
242
|
+
The code on screen is not static. A QR sign-in shows one frame of a sequence
|
|
243
|
+
the provider rotates every few seconds, and it refuses a frame the sequence has
|
|
244
|
+
already moved past. That is what stops a code being relayed: a screenshot of a
|
|
245
|
+
sign-in code, sent to someone else to scan and approve, is spent before it
|
|
246
|
+
arrives, so the picture buys an attacker nothing. The SDK re-fetches the frame
|
|
247
|
+
on the provider's cadence and the modal loads the next one before it swaps, so
|
|
248
|
+
the code never blinks out while a camera is aimed at it.
|
|
249
|
+
|
|
242
250
|
### Languages
|
|
243
251
|
|
|
244
252
|
Arabic · Bengali · Bosnian · Bulgarian · Chinese (Simplified) · Chinese (Traditional) ·
|
|
@@ -266,9 +274,12 @@ If you already have a design system you want the QR to live inside, opt out:
|
|
|
266
274
|
```
|
|
267
275
|
|
|
268
276
|
`onPairingStateChange` then carries everything you need on every state:
|
|
269
|
-
`qrUrl`, `pairUrl`, `status`, `expiresIn` and `cancel`.
|
|
270
|
-
`<img>`; do not draw your own.
|
|
271
|
-
|
|
277
|
+
`qrUrl`, `pairUrl`, `status`, `expiresIn`, `qrRefreshSeconds` and `cancel`.
|
|
278
|
+
Render `qrUrl` in an `<img>`; do not draw your own. Render it **on every state
|
|
279
|
+
you are given**, never the first one you saw: `qrUrl` is a moving frame, the
|
|
280
|
+
SDK hands you a new one every few seconds, and a UI that caches the first
|
|
281
|
+
value shows a code that stops working. `PairingModal` is also exported if you
|
|
282
|
+
want the real dialog but on your own terms.
|
|
272
283
|
|
|
273
284
|
## Scopes and claims
|
|
274
285
|
|
package/dist/index.cjs
CHANGED
|
@@ -39,10 +39,11 @@ var import_react2 = require("react");
|
|
|
39
39
|
|
|
40
40
|
// src/wire.ts
|
|
41
41
|
var WIRE_VERSION = 1;
|
|
42
|
-
var SDK_VERSION = "0.2.
|
|
42
|
+
var SDK_VERSION = "0.2.9";
|
|
43
43
|
var DEFAULT_ISSUER = "https://id.zoreal.com";
|
|
44
44
|
var POLL_INTERVAL_MS = 2e3;
|
|
45
45
|
var POLL_INTERVAL_ENROLLING_MS = 5e3;
|
|
46
|
+
var DEFAULT_QR_REFRESH_SECONDS = 3;
|
|
46
47
|
|
|
47
48
|
// src/PairingModal.tsx
|
|
48
49
|
var import_react = require("react");
|
|
@@ -1147,6 +1148,23 @@ function PairingModal({
|
|
|
1147
1148
|
const deadlineRef = (0, import_react.useRef)(0);
|
|
1148
1149
|
const [remaining, setRemaining] = (0, import_react.useState)(Math.round(timeoutMs / 1e3));
|
|
1149
1150
|
const settled = state.status === "claimed" || state.status === "enrolling";
|
|
1151
|
+
const [frameUrl, setFrameUrl] = (0, import_react.useState)(qrUrl);
|
|
1152
|
+
(0, import_react.useEffect)(() => {
|
|
1153
|
+
if (settled || frameUrl === qrUrl) return;
|
|
1154
|
+
let abandoned = false;
|
|
1155
|
+
const next = new Image();
|
|
1156
|
+
next.onload = () => {
|
|
1157
|
+
if (!abandoned) setFrameUrl(qrUrl);
|
|
1158
|
+
};
|
|
1159
|
+
next.onerror = () => {
|
|
1160
|
+
};
|
|
1161
|
+
next.src = qrUrl;
|
|
1162
|
+
return () => {
|
|
1163
|
+
abandoned = true;
|
|
1164
|
+
next.onload = null;
|
|
1165
|
+
next.onerror = null;
|
|
1166
|
+
};
|
|
1167
|
+
}, [qrUrl, settled, frameUrl]);
|
|
1150
1168
|
const onCancelRef = (0, import_react.useRef)(onCancel);
|
|
1151
1169
|
onCancelRef.current = onCancel;
|
|
1152
1170
|
(0, import_react.useEffect)(() => {
|
|
@@ -1198,7 +1216,7 @@ function PairingModal({
|
|
|
1198
1216
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h2", { id: titleId, className: cx("title"), children: settled ? t.titleApprove : t.title }),
|
|
1199
1217
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("body-text"), children: body }),
|
|
1200
1218
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: cx("qr-well"), children: [
|
|
1201
|
-
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("img", { className: cx("qr"), "data-spent": settled, src:
|
|
1219
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("img", { className: cx("qr"), "data-spent": settled, src: frameUrl, alt: t.qrAlt, width: 180, height: 180 }),
|
|
1202
1220
|
settled && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: cx("qr-overlay"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: cx("qr-badge"), children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(IconPhone, {}) }) })
|
|
1203
1221
|
] }),
|
|
1204
1222
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: cx("status"), children: [
|
|
@@ -1336,55 +1354,99 @@ async function startPairing(issuer, params) {
|
|
|
1336
1354
|
}
|
|
1337
1355
|
return body;
|
|
1338
1356
|
}
|
|
1357
|
+
function qrRefreshSecondsOf(started) {
|
|
1358
|
+
const seconds = started.qr_refresh_seconds;
|
|
1359
|
+
return typeof seconds === "number" && seconds > 0 ? seconds : DEFAULT_QR_REFRESH_SECONDS;
|
|
1360
|
+
}
|
|
1361
|
+
function qrFrameUrl(issuer, requestId) {
|
|
1362
|
+
return `${issuer}/pair/${encodeURIComponent(requestId)}/qr.svg?t=${Date.now()}`;
|
|
1363
|
+
}
|
|
1339
1364
|
var sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
1340
1365
|
if (signal?.aborted) {
|
|
1341
1366
|
reject(new DOMException("aborted", "AbortError"));
|
|
1342
1367
|
return;
|
|
1343
1368
|
}
|
|
1344
|
-
const
|
|
1345
|
-
|
|
1346
|
-
clearTimeout(t);
|
|
1369
|
+
const onAbort = () => {
|
|
1370
|
+
clearTimeout(timer);
|
|
1347
1371
|
reject(new DOMException("aborted", "AbortError"));
|
|
1348
|
-
}
|
|
1372
|
+
};
|
|
1373
|
+
const timer = setTimeout(() => {
|
|
1374
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1375
|
+
resolve();
|
|
1376
|
+
}, ms);
|
|
1377
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1349
1378
|
});
|
|
1350
|
-
async function pollUntilApproved(issuer, requestId, onState, signal) {
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1379
|
+
async function pollUntilApproved(issuer, requestId, onState, signal, options = {}) {
|
|
1380
|
+
let last = { status: "pending" };
|
|
1381
|
+
const emit = (state) => {
|
|
1382
|
+
last = state;
|
|
1383
|
+
onState?.(state);
|
|
1384
|
+
};
|
|
1385
|
+
let frames = null;
|
|
1386
|
+
const stopFrames = () => {
|
|
1387
|
+
frames?.abort();
|
|
1388
|
+
frames = null;
|
|
1389
|
+
};
|
|
1390
|
+
const startFrames = () => {
|
|
1391
|
+
const seconds = options.qrRefreshSeconds;
|
|
1392
|
+
if (frames || signal?.aborted || typeof seconds !== "number" || !(seconds > 0)) return;
|
|
1393
|
+
const period = seconds * 1e3;
|
|
1394
|
+
const controller = new AbortController();
|
|
1395
|
+
frames = controller;
|
|
1396
|
+
signal?.addEventListener("abort", stopFrames, { signal: controller.signal });
|
|
1397
|
+
void (async () => {
|
|
1398
|
+
let due = Date.now() + period;
|
|
1399
|
+
for (; ; ) {
|
|
1400
|
+
await sleep(Math.max(0, due - Date.now()), controller.signal);
|
|
1401
|
+
emit({ ...last, qrUrl: qrFrameUrl(issuer, requestId) });
|
|
1402
|
+
due = Date.now() + period;
|
|
1403
|
+
}
|
|
1404
|
+
})().catch(() => {
|
|
1366
1405
|
});
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1406
|
+
};
|
|
1407
|
+
try {
|
|
1408
|
+
for (; ; ) {
|
|
1409
|
+
const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
|
|
1410
|
+
signal
|
|
1411
|
+
});
|
|
1412
|
+
const body = await parseJson(response);
|
|
1413
|
+
if (!response.ok) {
|
|
1414
|
+
throw new OAuthFlowError(
|
|
1415
|
+
body.error ?? "server_error",
|
|
1416
|
+
body.error_description ?? `Pairing status failed (${response.status})`
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
emit({
|
|
1420
|
+
status: body.status,
|
|
1421
|
+
expiresIn: body.expires_in,
|
|
1422
|
+
enrolmentDeadline: body.enrolment_deadline
|
|
1423
|
+
});
|
|
1424
|
+
if (body.status === "pending") startFrames();
|
|
1425
|
+
else stopFrames();
|
|
1426
|
+
switch (body.status) {
|
|
1427
|
+
case "approved":
|
|
1428
|
+
if (!body.code) {
|
|
1429
|
+
throw new OAuthFlowError("server_error", "approved with no authorization code");
|
|
1430
|
+
}
|
|
1431
|
+
return body.code;
|
|
1432
|
+
case "denied":
|
|
1433
|
+
throw new FlowAbandonedError({ type: "request_denied", description: body.error_description });
|
|
1434
|
+
case "expired":
|
|
1435
|
+
throw new FlowAbandonedError({ type: "request_expired", description: body.error_description });
|
|
1436
|
+
case "cancelled":
|
|
1437
|
+
throw new FlowAbandonedError({
|
|
1438
|
+
type: "request_expired",
|
|
1439
|
+
description: body.error_description ?? "the provider cancelled the pairing request"
|
|
1440
|
+
});
|
|
1441
|
+
case "enrolling":
|
|
1442
|
+
await sleep(POLL_INTERVAL_ENROLLING_MS, signal);
|
|
1443
|
+
break;
|
|
1444
|
+
default:
|
|
1445
|
+
await sleep(POLL_INTERVAL_MS, signal);
|
|
1446
|
+
}
|
|
1387
1447
|
}
|
|
1448
|
+
} finally {
|
|
1449
|
+
stopFrames();
|
|
1388
1450
|
}
|
|
1389
1451
|
}
|
|
1390
1452
|
async function exchangeCode(issuer, input) {
|
|
@@ -1411,6 +1473,11 @@ function isMobileUserAgent() {
|
|
|
1411
1473
|
if (typeof navigator === "undefined") return false;
|
|
1412
1474
|
return /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
1413
1475
|
}
|
|
1476
|
+
function resolveDisplay(display) {
|
|
1477
|
+
if (display === "link") return "link";
|
|
1478
|
+
if (display === "qr") return "qr";
|
|
1479
|
+
return isMobileUserAgent() ? "link" : "qr";
|
|
1480
|
+
}
|
|
1414
1481
|
|
|
1415
1482
|
// src/pkce.ts
|
|
1416
1483
|
var VERIFIER_BYTES = 32;
|
|
@@ -1457,6 +1524,8 @@ function useZorealFlow(options) {
|
|
|
1457
1524
|
const verifier = generateVerifier();
|
|
1458
1525
|
const state = generateState();
|
|
1459
1526
|
const nonce = generateState();
|
|
1527
|
+
const display = resolveDisplay(opts.display);
|
|
1528
|
+
const useAppLink = display === "link";
|
|
1460
1529
|
try {
|
|
1461
1530
|
const started = await startPairing(issuer, {
|
|
1462
1531
|
client_id: clientId,
|
|
@@ -1468,7 +1537,8 @@ function useZorealFlow(options) {
|
|
|
1468
1537
|
acr_values: Array.isArray(opts.acr_values) ? opts.acr_values.join(" ") : opts.acr_values,
|
|
1469
1538
|
max_age: opts.max_age,
|
|
1470
1539
|
prompt: opts.prompt,
|
|
1471
|
-
locale
|
|
1540
|
+
locale,
|
|
1541
|
+
display
|
|
1472
1542
|
});
|
|
1473
1543
|
let code;
|
|
1474
1544
|
let selectBy = "device";
|
|
@@ -1476,8 +1546,8 @@ function useZorealFlow(options) {
|
|
|
1476
1546
|
code = started.code;
|
|
1477
1547
|
selectBy = "session";
|
|
1478
1548
|
} else {
|
|
1479
|
-
const useAppLink = opts.display === "link" || opts.display !== "qr" && isMobileUserAgent();
|
|
1480
1549
|
selectBy = useAppLink ? "app_link" : "qr";
|
|
1550
|
+
const qrRefreshSeconds = qrRefreshSecondsOf(started);
|
|
1481
1551
|
const cancel = () => {
|
|
1482
1552
|
controller.abort();
|
|
1483
1553
|
setPairing(null);
|
|
@@ -1485,21 +1555,23 @@ function useZorealFlow(options) {
|
|
|
1485
1555
|
};
|
|
1486
1556
|
const surface = {
|
|
1487
1557
|
pairUrl: started.pair_url,
|
|
1488
|
-
qrUrl: `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`,
|
|
1489
1558
|
appLink: useAppLink,
|
|
1490
|
-
cancel
|
|
1559
|
+
cancel,
|
|
1560
|
+
// The app link has no QR and therefore no cadence to report.
|
|
1561
|
+
...useAppLink ? null : { qrRefreshSeconds }
|
|
1491
1562
|
};
|
|
1563
|
+
let qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;
|
|
1492
1564
|
const active = {
|
|
1493
1565
|
requestId: started.request_id,
|
|
1494
1566
|
pairUrl: surface.pairUrl,
|
|
1495
|
-
qrUrl
|
|
1496
|
-
state: { status: "pending", expiresIn: started.expires_in, ...surface },
|
|
1567
|
+
qrUrl,
|
|
1568
|
+
state: { status: "pending", expiresIn: started.expires_in, qrUrl, ...surface },
|
|
1497
1569
|
appLink: useAppLink,
|
|
1498
1570
|
cancel
|
|
1499
1571
|
};
|
|
1500
1572
|
setPairing(active);
|
|
1501
1573
|
if (!useAppLink) {
|
|
1502
|
-
publishRef.current?.({ state: active.state, qrUrl
|
|
1574
|
+
publishRef.current?.({ state: active.state, qrUrl, cancel });
|
|
1503
1575
|
}
|
|
1504
1576
|
opts.onPairingStateChange?.(active.state);
|
|
1505
1577
|
if (useAppLink) {
|
|
@@ -1509,16 +1581,20 @@ function useZorealFlow(options) {
|
|
|
1509
1581
|
issuer,
|
|
1510
1582
|
started.request_id,
|
|
1511
1583
|
(s) => {
|
|
1512
|
-
|
|
1584
|
+
if (s.qrUrl) qrUrl = s.qrUrl;
|
|
1585
|
+
const enriched = { ...s, ...surface, qrUrl };
|
|
1513
1586
|
setPairing(
|
|
1514
|
-
(p) => p && p.requestId === started.request_id ? { ...p, state: enriched } : p
|
|
1587
|
+
(p) => p && p.requestId === started.request_id ? { ...p, qrUrl, state: enriched } : p
|
|
1515
1588
|
);
|
|
1516
1589
|
if (!useAppLink) {
|
|
1517
|
-
publishRef.current?.({ state: enriched, qrUrl
|
|
1590
|
+
publishRef.current?.({ state: enriched, qrUrl, cancel });
|
|
1518
1591
|
}
|
|
1519
1592
|
opts.onPairingStateChange?.(enriched);
|
|
1520
1593
|
},
|
|
1521
|
-
controller.signal
|
|
1594
|
+
controller.signal,
|
|
1595
|
+
// The app link has no QR to animate, and the provider answers 404
|
|
1596
|
+
// for one, so the frames are asked for only on the QR surface.
|
|
1597
|
+
{ qrRefreshSeconds: useAppLink ? void 0 : qrRefreshSeconds }
|
|
1522
1598
|
);
|
|
1523
1599
|
}
|
|
1524
1600
|
setPairing(null);
|