@zoreal/oauth2-react 0.2.7 → 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/dist/index.d.cts CHANGED
@@ -37,8 +37,25 @@ interface PairingState {
37
37
  * carrying nothing to render. Present on every callback of a QR/link flow.
38
38
  */
39
39
  pairUrl?: string;
40
- /** The provider-served SVG of pairUrl. Put it in an <img>; do not draw your own. */
40
+ /**
41
+ * The provider-served SVG to show right now. Put it in an <img>; do not draw
42
+ * your own.
43
+ *
44
+ * IT CHANGES DURING THE PAIRING. The code on screen is one frame of a
45
+ * rotating sequence, and a frame the provider has moved past is refused when
46
+ * the phone tries to claim it, which is what stops a screenshot of the code
47
+ * from being relayed to someone else's browser. So render the qrUrl of the
48
+ * state you are handed, every time you are handed one; a UI that caches the
49
+ * first value shows a code that stops working seconds later.
50
+ */
41
51
  qrUrl?: string;
52
+ /**
53
+ * How often, in seconds, `qrUrl` is replaced while the pairing is pending.
54
+ * Informational: the SDK already emits a new state on that cadence, so a
55
+ * custom UI does not need a timer of its own. Absent on the app link, which
56
+ * has no QR.
57
+ */
58
+ qrRefreshSeconds?: number;
42
59
  /** True when the flow resolved to the app link (mobile) rather than a QR. */
43
60
  appLink?: boolean;
44
61
  /** Abandons this pairing: stops the poll. Wire it to your UI's cancel control. */
@@ -71,10 +88,13 @@ interface ZorealButtonConfiguration {
71
88
  theme?: 'outline' | 'filled' | 'filled_black';
72
89
  size?: 'large' | 'medium' | 'small';
73
90
  /**
74
- * All four are neutral. The button asserts nothing about a person who has not yet
75
- * authenticated; there is no 'verified_human' variant.
91
+ * All five are neutral. The button asserts nothing about a person who has
92
+ * not yet authenticated; there is no 'verified_human' variant. 'verify_with'
93
+ * stays inside that rule: it names the act the button starts, not a state
94
+ * the person already holds — built for surfaces where the login IS a check
95
+ * (ZOREAL Meet's presence request), not a sign-in.
76
96
  */
77
- text?: 'continue_with' | 'signin_with' | 'signup_with' | 'signin';
97
+ text?: 'continue_with' | 'signin_with' | 'signup_with' | 'signin' | 'verify_with';
78
98
  shape?: 'rectangular' | 'pill' | 'square';
79
99
  logo_alignment?: 'left' | 'center';
80
100
  width?: string | number;
@@ -126,8 +146,21 @@ interface AuthCodeFlowOptions extends ZorealLoginRequestOptions {
126
146
  redirect_uri?: string;
127
147
  ux_mode?: 'popup' | 'redirect';
128
148
  }
129
- type ZorealLoginProps = {
149
+ /**
150
+ * The button runs either flow, discriminated on `flow` exactly as
151
+ * useZorealLogin is: browser-direct (the default) hands onSuccess an ID
152
+ * token; auth-code hands it the code, PKCE verifier and nonce for YOUR
153
+ * backend to redeem. Added in 0.2.8 — before that the button was
154
+ * browser-direct only and auth-code integrations had to rebuild the button
155
+ * around the hook to get the same pixels.
156
+ */
157
+ type ZorealLoginProps = ({
158
+ flow?: 'browser-direct';
130
159
  onSuccess: (response: ZorealCredentialResponse) => void;
160
+ } | {
161
+ flow: 'auth-code';
162
+ onSuccess: (response: ZorealCodeResponse) => void;
163
+ }) & {
131
164
  onError?: (error: NonOAuthError) => void;
132
165
  containerProps?: React.ComponentPropsWithoutRef<'div'>;
133
166
  } & ZorealLoginRequestOptions & ZorealButtonConfiguration;
package/dist/index.d.ts CHANGED
@@ -37,8 +37,25 @@ interface PairingState {
37
37
  * carrying nothing to render. Present on every callback of a QR/link flow.
38
38
  */
39
39
  pairUrl?: string;
40
- /** The provider-served SVG of pairUrl. Put it in an <img>; do not draw your own. */
40
+ /**
41
+ * The provider-served SVG to show right now. Put it in an <img>; do not draw
42
+ * your own.
43
+ *
44
+ * IT CHANGES DURING THE PAIRING. The code on screen is one frame of a
45
+ * rotating sequence, and a frame the provider has moved past is refused when
46
+ * the phone tries to claim it, which is what stops a screenshot of the code
47
+ * from being relayed to someone else's browser. So render the qrUrl of the
48
+ * state you are handed, every time you are handed one; a UI that caches the
49
+ * first value shows a code that stops working seconds later.
50
+ */
41
51
  qrUrl?: string;
52
+ /**
53
+ * How often, in seconds, `qrUrl` is replaced while the pairing is pending.
54
+ * Informational: the SDK already emits a new state on that cadence, so a
55
+ * custom UI does not need a timer of its own. Absent on the app link, which
56
+ * has no QR.
57
+ */
58
+ qrRefreshSeconds?: number;
42
59
  /** True when the flow resolved to the app link (mobile) rather than a QR. */
43
60
  appLink?: boolean;
44
61
  /** Abandons this pairing: stops the poll. Wire it to your UI's cancel control. */
@@ -71,10 +88,13 @@ interface ZorealButtonConfiguration {
71
88
  theme?: 'outline' | 'filled' | 'filled_black';
72
89
  size?: 'large' | 'medium' | 'small';
73
90
  /**
74
- * All four are neutral. The button asserts nothing about a person who has not yet
75
- * authenticated; there is no 'verified_human' variant.
91
+ * All five are neutral. The button asserts nothing about a person who has
92
+ * not yet authenticated; there is no 'verified_human' variant. 'verify_with'
93
+ * stays inside that rule: it names the act the button starts, not a state
94
+ * the person already holds — built for surfaces where the login IS a check
95
+ * (ZOREAL Meet's presence request), not a sign-in.
76
96
  */
77
- text?: 'continue_with' | 'signin_with' | 'signup_with' | 'signin';
97
+ text?: 'continue_with' | 'signin_with' | 'signup_with' | 'signin' | 'verify_with';
78
98
  shape?: 'rectangular' | 'pill' | 'square';
79
99
  logo_alignment?: 'left' | 'center';
80
100
  width?: string | number;
@@ -126,8 +146,21 @@ interface AuthCodeFlowOptions extends ZorealLoginRequestOptions {
126
146
  redirect_uri?: string;
127
147
  ux_mode?: 'popup' | 'redirect';
128
148
  }
129
- type ZorealLoginProps = {
149
+ /**
150
+ * The button runs either flow, discriminated on `flow` exactly as
151
+ * useZorealLogin is: browser-direct (the default) hands onSuccess an ID
152
+ * token; auth-code hands it the code, PKCE verifier and nonce for YOUR
153
+ * backend to redeem. Added in 0.2.8 — before that the button was
154
+ * browser-direct only and auth-code integrations had to rebuild the button
155
+ * around the hook to get the same pixels.
156
+ */
157
+ type ZorealLoginProps = ({
158
+ flow?: 'browser-direct';
130
159
  onSuccess: (response: ZorealCredentialResponse) => void;
160
+ } | {
161
+ flow: 'auth-code';
162
+ onSuccess: (response: ZorealCodeResponse) => void;
163
+ }) & {
131
164
  onError?: (error: NonOAuthError) => void;
132
165
  containerProps?: React.ComponentPropsWithoutRef<'div'>;
133
166
  } & ZorealLoginRequestOptions & ZorealButtonConfiguration;
package/dist/index.js CHANGED
@@ -5,10 +5,11 @@ import { createContext, useContext, useMemo, useState as useState2 } from "react
5
5
 
6
6
  // src/wire.ts
7
7
  var WIRE_VERSION = 1;
8
- var SDK_VERSION = "0.2.7";
8
+ var SDK_VERSION = "0.2.9";
9
9
  var DEFAULT_ISSUER = "https://id.zoreal.com";
10
10
  var POLL_INTERVAL_MS = 2e3;
11
11
  var POLL_INTERVAL_ENROLLING_MS = 5e3;
12
+ var DEFAULT_QR_REFRESH_SECONDS = 3;
12
13
 
13
14
  // src/PairingModal.tsx
14
15
  import { useEffect, useId, useRef, useState } from "react";
@@ -1113,6 +1114,23 @@ function PairingModal({
1113
1114
  const deadlineRef = useRef(0);
1114
1115
  const [remaining, setRemaining] = useState(Math.round(timeoutMs / 1e3));
1115
1116
  const settled = state.status === "claimed" || state.status === "enrolling";
1117
+ const [frameUrl, setFrameUrl] = useState(qrUrl);
1118
+ useEffect(() => {
1119
+ if (settled || frameUrl === qrUrl) return;
1120
+ let abandoned = false;
1121
+ const next = new Image();
1122
+ next.onload = () => {
1123
+ if (!abandoned) setFrameUrl(qrUrl);
1124
+ };
1125
+ next.onerror = () => {
1126
+ };
1127
+ next.src = qrUrl;
1128
+ return () => {
1129
+ abandoned = true;
1130
+ next.onload = null;
1131
+ next.onerror = null;
1132
+ };
1133
+ }, [qrUrl, settled, frameUrl]);
1116
1134
  const onCancelRef = useRef(onCancel);
1117
1135
  onCancelRef.current = onCancel;
1118
1136
  useEffect(() => {
@@ -1164,7 +1182,7 @@ function PairingModal({
1164
1182
  /* @__PURE__ */ jsx3("h2", { id: titleId, className: cx("title"), children: settled ? t.titleApprove : t.title }),
1165
1183
  /* @__PURE__ */ jsx3("p", { className: cx("body-text"), children: body }),
1166
1184
  /* @__PURE__ */ jsxs3("div", { className: cx("qr-well"), children: [
1167
- /* @__PURE__ */ jsx3("img", { className: cx("qr"), "data-spent": settled, src: qrUrl, alt: t.qrAlt, width: 180, height: 180 }),
1185
+ /* @__PURE__ */ jsx3("img", { className: cx("qr"), "data-spent": settled, src: frameUrl, alt: t.qrAlt, width: 180, height: 180 }),
1168
1186
  settled && /* @__PURE__ */ jsx3("span", { className: cx("qr-overlay"), children: /* @__PURE__ */ jsx3("span", { className: cx("qr-badge"), children: /* @__PURE__ */ jsx3(IconPhone, {}) }) })
1169
1187
  ] }),
1170
1188
  /* @__PURE__ */ jsxs3("div", { className: cx("status"), children: [
@@ -1302,55 +1320,99 @@ async function startPairing(issuer, params) {
1302
1320
  }
1303
1321
  return body;
1304
1322
  }
1323
+ function qrRefreshSecondsOf(started) {
1324
+ const seconds = started.qr_refresh_seconds;
1325
+ return typeof seconds === "number" && seconds > 0 ? seconds : DEFAULT_QR_REFRESH_SECONDS;
1326
+ }
1327
+ function qrFrameUrl(issuer, requestId) {
1328
+ return `${issuer}/pair/${encodeURIComponent(requestId)}/qr.svg?t=${Date.now()}`;
1329
+ }
1305
1330
  var sleep = (ms, signal) => new Promise((resolve, reject) => {
1306
1331
  if (signal?.aborted) {
1307
1332
  reject(new DOMException("aborted", "AbortError"));
1308
1333
  return;
1309
1334
  }
1310
- const t = setTimeout(resolve, ms);
1311
- signal?.addEventListener("abort", () => {
1312
- clearTimeout(t);
1335
+ const onAbort = () => {
1336
+ clearTimeout(timer);
1313
1337
  reject(new DOMException("aborted", "AbortError"));
1314
- });
1338
+ };
1339
+ const timer = setTimeout(() => {
1340
+ signal?.removeEventListener("abort", onAbort);
1341
+ resolve();
1342
+ }, ms);
1343
+ signal?.addEventListener("abort", onAbort, { once: true });
1315
1344
  });
1316
- async function pollUntilApproved(issuer, requestId, onState, signal) {
1317
- for (; ; ) {
1318
- const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
1319
- signal
1320
- });
1321
- const body = await parseJson(response);
1322
- if (!response.ok) {
1323
- throw new OAuthFlowError(
1324
- body.error ?? "server_error",
1325
- body.error_description ?? `Pairing status failed (${response.status})`
1326
- );
1327
- }
1328
- onState?.({
1329
- status: body.status,
1330
- expiresIn: body.expires_in,
1331
- enrolmentDeadline: body.enrolment_deadline
1345
+ async function pollUntilApproved(issuer, requestId, onState, signal, options = {}) {
1346
+ let last = { status: "pending" };
1347
+ const emit = (state) => {
1348
+ last = state;
1349
+ onState?.(state);
1350
+ };
1351
+ let frames = null;
1352
+ const stopFrames = () => {
1353
+ frames?.abort();
1354
+ frames = null;
1355
+ };
1356
+ const startFrames = () => {
1357
+ const seconds = options.qrRefreshSeconds;
1358
+ if (frames || signal?.aborted || typeof seconds !== "number" || !(seconds > 0)) return;
1359
+ const period = seconds * 1e3;
1360
+ const controller = new AbortController();
1361
+ frames = controller;
1362
+ signal?.addEventListener("abort", stopFrames, { signal: controller.signal });
1363
+ void (async () => {
1364
+ let due = Date.now() + period;
1365
+ for (; ; ) {
1366
+ await sleep(Math.max(0, due - Date.now()), controller.signal);
1367
+ emit({ ...last, qrUrl: qrFrameUrl(issuer, requestId) });
1368
+ due = Date.now() + period;
1369
+ }
1370
+ })().catch(() => {
1332
1371
  });
1333
- switch (body.status) {
1334
- case "approved":
1335
- if (!body.code) {
1336
- throw new OAuthFlowError("server_error", "approved with no authorization code");
1337
- }
1338
- return body.code;
1339
- case "denied":
1340
- throw new FlowAbandonedError({ type: "request_denied", description: body.error_description });
1341
- case "expired":
1342
- throw new FlowAbandonedError({ type: "request_expired", description: body.error_description });
1343
- case "cancelled":
1344
- throw new FlowAbandonedError({
1345
- type: "request_expired",
1346
- description: body.error_description ?? "the provider cancelled the pairing request"
1347
- });
1348
- case "enrolling":
1349
- await sleep(POLL_INTERVAL_ENROLLING_MS, signal);
1350
- break;
1351
- default:
1352
- await sleep(POLL_INTERVAL_MS, signal);
1372
+ };
1373
+ try {
1374
+ for (; ; ) {
1375
+ const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
1376
+ signal
1377
+ });
1378
+ const body = await parseJson(response);
1379
+ if (!response.ok) {
1380
+ throw new OAuthFlowError(
1381
+ body.error ?? "server_error",
1382
+ body.error_description ?? `Pairing status failed (${response.status})`
1383
+ );
1384
+ }
1385
+ emit({
1386
+ status: body.status,
1387
+ expiresIn: body.expires_in,
1388
+ enrolmentDeadline: body.enrolment_deadline
1389
+ });
1390
+ if (body.status === "pending") startFrames();
1391
+ else stopFrames();
1392
+ switch (body.status) {
1393
+ case "approved":
1394
+ if (!body.code) {
1395
+ throw new OAuthFlowError("server_error", "approved with no authorization code");
1396
+ }
1397
+ return body.code;
1398
+ case "denied":
1399
+ throw new FlowAbandonedError({ type: "request_denied", description: body.error_description });
1400
+ case "expired":
1401
+ throw new FlowAbandonedError({ type: "request_expired", description: body.error_description });
1402
+ case "cancelled":
1403
+ throw new FlowAbandonedError({
1404
+ type: "request_expired",
1405
+ description: body.error_description ?? "the provider cancelled the pairing request"
1406
+ });
1407
+ case "enrolling":
1408
+ await sleep(POLL_INTERVAL_ENROLLING_MS, signal);
1409
+ break;
1410
+ default:
1411
+ await sleep(POLL_INTERVAL_MS, signal);
1412
+ }
1353
1413
  }
1414
+ } finally {
1415
+ stopFrames();
1354
1416
  }
1355
1417
  }
1356
1418
  async function exchangeCode(issuer, input) {
@@ -1377,6 +1439,11 @@ function isMobileUserAgent() {
1377
1439
  if (typeof navigator === "undefined") return false;
1378
1440
  return /android|iphone|ipad|ipod/i.test(navigator.userAgent);
1379
1441
  }
1442
+ function resolveDisplay(display) {
1443
+ if (display === "link") return "link";
1444
+ if (display === "qr") return "qr";
1445
+ return isMobileUserAgent() ? "link" : "qr";
1446
+ }
1380
1447
 
1381
1448
  // src/pkce.ts
1382
1449
  var VERIFIER_BYTES = 32;
@@ -1423,6 +1490,8 @@ function useZorealFlow(options) {
1423
1490
  const verifier = generateVerifier();
1424
1491
  const state = generateState();
1425
1492
  const nonce = generateState();
1493
+ const display = resolveDisplay(opts.display);
1494
+ const useAppLink = display === "link";
1426
1495
  try {
1427
1496
  const started = await startPairing(issuer, {
1428
1497
  client_id: clientId,
@@ -1434,7 +1503,8 @@ function useZorealFlow(options) {
1434
1503
  acr_values: Array.isArray(opts.acr_values) ? opts.acr_values.join(" ") : opts.acr_values,
1435
1504
  max_age: opts.max_age,
1436
1505
  prompt: opts.prompt,
1437
- locale
1506
+ locale,
1507
+ display
1438
1508
  });
1439
1509
  let code;
1440
1510
  let selectBy = "device";
@@ -1442,8 +1512,8 @@ function useZorealFlow(options) {
1442
1512
  code = started.code;
1443
1513
  selectBy = "session";
1444
1514
  } else {
1445
- const useAppLink = opts.display === "link" || opts.display !== "qr" && isMobileUserAgent();
1446
1515
  selectBy = useAppLink ? "app_link" : "qr";
1516
+ const qrRefreshSeconds = qrRefreshSecondsOf(started);
1447
1517
  const cancel = () => {
1448
1518
  controller.abort();
1449
1519
  setPairing(null);
@@ -1451,21 +1521,23 @@ function useZorealFlow(options) {
1451
1521
  };
1452
1522
  const surface = {
1453
1523
  pairUrl: started.pair_url,
1454
- qrUrl: `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`,
1455
1524
  appLink: useAppLink,
1456
- cancel
1525
+ cancel,
1526
+ // The app link has no QR and therefore no cadence to report.
1527
+ ...useAppLink ? null : { qrRefreshSeconds }
1457
1528
  };
1529
+ let qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;
1458
1530
  const active = {
1459
1531
  requestId: started.request_id,
1460
1532
  pairUrl: surface.pairUrl,
1461
- qrUrl: surface.qrUrl,
1462
- state: { status: "pending", expiresIn: started.expires_in, ...surface },
1533
+ qrUrl,
1534
+ state: { status: "pending", expiresIn: started.expires_in, qrUrl, ...surface },
1463
1535
  appLink: useAppLink,
1464
1536
  cancel
1465
1537
  };
1466
1538
  setPairing(active);
1467
1539
  if (!useAppLink) {
1468
- publishRef.current?.({ state: active.state, qrUrl: surface.qrUrl, cancel });
1540
+ publishRef.current?.({ state: active.state, qrUrl, cancel });
1469
1541
  }
1470
1542
  opts.onPairingStateChange?.(active.state);
1471
1543
  if (useAppLink) {
@@ -1475,16 +1547,20 @@ function useZorealFlow(options) {
1475
1547
  issuer,
1476
1548
  started.request_id,
1477
1549
  (s) => {
1478
- const enriched = { ...s, ...surface };
1550
+ if (s.qrUrl) qrUrl = s.qrUrl;
1551
+ const enriched = { ...s, ...surface, qrUrl };
1479
1552
  setPairing(
1480
- (p) => p && p.requestId === started.request_id ? { ...p, state: enriched } : p
1553
+ (p) => p && p.requestId === started.request_id ? { ...p, qrUrl, state: enriched } : p
1481
1554
  );
1482
1555
  if (!useAppLink) {
1483
- publishRef.current?.({ state: enriched, qrUrl: surface.qrUrl, cancel });
1556
+ publishRef.current?.({ state: enriched, qrUrl, cancel });
1484
1557
  }
1485
1558
  opts.onPairingStateChange?.(enriched);
1486
1559
  },
1487
- controller.signal
1560
+ controller.signal,
1561
+ // The app link has no QR to animate, and the provider answers 404
1562
+ // for one, so the frames are asked for only on the QR surface.
1563
+ { qrRefreshSeconds: useAppLink ? void 0 : qrRefreshSeconds }
1488
1564
  );
1489
1565
  }
1490
1566
  setPairing(null);
@@ -1555,7 +1631,8 @@ var TEXTS = {
1555
1631
  continue_with: "Continue with ZOREAL",
1556
1632
  signin_with: "Sign in with ZOREAL",
1557
1633
  signup_with: "Sign up with ZOREAL",
1558
- signin: "Sign in"
1634
+ signin: "Sign in",
1635
+ verify_with: "Verify with ZOREAL ID"
1559
1636
  };
1560
1637
  var SIZES = {
1561
1638
  large: { height: 44, font: 15, pad: 20 },
@@ -1575,12 +1652,14 @@ function ZorealLogin(props) {
1575
1652
  logo_alignment = "left",
1576
1653
  width,
1577
1654
  click_listener,
1655
+ flow = "browser-direct",
1578
1656
  ...request
1579
1657
  } = props;
1580
1658
  const { login } = useZorealFlow({
1581
1659
  ...request,
1582
- flow: "browser-direct",
1583
- onCredential: onSuccess,
1660
+ flow,
1661
+ onCredential: flow === "browser-direct" ? onSuccess : void 0,
1662
+ onCode: flow === "auth-code" ? onSuccess : void 0,
1584
1663
  onError: (e) => onError?.({ type: "unknown", description: e.description ?? e.error }),
1585
1664
  onNonOAuthError: (e) => onError?.(e)
1586
1665
  });