@zoreal/oauth2-react 0.2.16 → 0.2.18

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
@@ -251,7 +251,7 @@ What the modal does:
251
251
 
252
252
  | | |
253
253
  | --- | --- |
254
- | **Mobile** | No QR and no modal. The SDK creates the pairing and sends the tab to the pairing link, which the ZOREAL ID app claims; with no app installed the same link is the page that installs it. That is one round trip after the tap: `ZorealLogin` disables itself and runs a light round its edge for that gap; a site with its own button keeps that button and draws its own busy state from the tap until `onSuccess` or `onError` fires, or wraps it in `ZorealBusyRing` to get the same light (a wrapper: pass `block` for a full-width button, keep `overflow: hidden` off its ancestors, and expect `.parent > button` selectors to stop matching). Force one or the other with `display: 'qr'` / `'link'`. |
254
+ | **Mobile** | No QR and no modal. The tap itself is a navigation: the SDK sends the tab to the provider's `/pair/start` with the pairing's parameters, synchronously from the click, 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 `login()` from the click handler itself: `ZorealLogin` does, disables itself and runs a light round its edge until the flow ends; a site with its own button keeps that button and draws its own busy state from the tap until `onSuccess` or `onError` fires, or wraps it in `ZorealBusyRing` to get the same light (a wrapper: pass `block` for a full-width button, keep `overflow: hidden` off its ancestors, and expect `.parent > button` selectors to stop matching). Force one or the other with `display: 'qr'` / `'link'`. |
255
255
  | **Live status** | The copy and the title follow the pairing: waiting for a scan, then waiting for approval once the holder has claimed the code (the spent QR blurs out behind a phone glyph). |
256
256
  | **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. |
257
257
  | **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
@@ -41,7 +41,7 @@ var import_react2 = require("react");
41
41
 
42
42
  // src/wire.ts
43
43
  var WIRE_VERSION = 1;
44
- var SDK_VERSION = "0.2.16";
44
+ var SDK_VERSION = "0.2.18";
45
45
  var DEFAULT_ISSUER = "https://id.zoreal.com";
46
46
  var POLL_INTERVAL_MS = 2e3;
47
47
  var POLL_INTERVAL_ENROLLING_MS = 5e3;
@@ -1691,6 +1691,20 @@ async function startPairing(issuer, params) {
1691
1691
  }
1692
1692
  return body;
1693
1693
  }
1694
+ function sameDeviceStartUrl(issuer, params) {
1695
+ const query = new URLSearchParams();
1696
+ const all = {
1697
+ ...params,
1698
+ code_challenge_method: "S256",
1699
+ wire_version: WIRE_VERSION,
1700
+ sdk: `@zoreal/oauth2-react/${SDK_VERSION}`
1701
+ };
1702
+ for (const [key, value] of Object.entries(all)) {
1703
+ if (value === void 0 || value === null || value === "") continue;
1704
+ query.set(key, String(value));
1705
+ }
1706
+ return `${issuer}/pair/start?${query.toString()}`;
1707
+ }
1694
1708
  function qrRefreshSecondsOf(started) {
1695
1709
  const seconds = started.qr_refresh_seconds;
1696
1710
  return typeof seconds === "number" && seconds > 0 ? seconds : DEFAULT_QR_REFRESH_SECONDS;
@@ -1713,6 +1727,8 @@ var sleep = (ms, signal) => new Promise((resolve, reject) => {
1713
1727
  }, ms);
1714
1728
  signal?.addEventListener("abort", onAbort, { once: true });
1715
1729
  });
1730
+ var SETTLE_MS = 1500;
1731
+ var NETWORK_FAILURES_TOLERATED = 4;
1716
1732
  async function pollUntilApproved(issuer, requestId, onState, signal, options = {}) {
1717
1733
  let last = { status: "pending" };
1718
1734
  const emit = (state) => {
@@ -1741,12 +1757,33 @@ async function pollUntilApproved(issuer, requestId, onState, signal, options = {
1741
1757
  })().catch(() => {
1742
1758
  });
1743
1759
  };
1760
+ const settlingUntil = options.tolerateUnknownUntil ?? 0;
1761
+ let networkFailures = 0;
1744
1762
  try {
1763
+ if (settlingUntil > Date.now()) await sleep(SETTLE_MS, signal);
1745
1764
  for (; ; ) {
1746
- const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
1747
- signal
1748
- });
1765
+ let response;
1766
+ try {
1767
+ response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
1768
+ signal
1769
+ });
1770
+ networkFailures = 0;
1771
+ } catch (e) {
1772
+ if (e instanceof DOMException && e.name === "AbortError") throw e;
1773
+ networkFailures += 1;
1774
+ if (settlingUntil > Date.now() || networkFailures <= NETWORK_FAILURES_TOLERATED) {
1775
+ emit({ ...last, status: last.status });
1776
+ await sleep(POLL_INTERVAL_MS, signal);
1777
+ continue;
1778
+ }
1779
+ throw e;
1780
+ }
1749
1781
  const body = await parseJson(response);
1782
+ if (response.status === 404 && (options.tolerateUnknownUntil ?? 0) > Date.now()) {
1783
+ emit({ status: "pending" });
1784
+ await sleep(POLL_INTERVAL_MS, signal);
1785
+ continue;
1786
+ }
1750
1787
  if (!response.ok) {
1751
1788
  throw new OAuthFlowError(
1752
1789
  body.error ?? "server_error",
@@ -1828,11 +1865,154 @@ async function challengeS256(verifier) {
1828
1865
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
1829
1866
  return base64url(new Uint8Array(digest));
1830
1867
  }
1868
+ function challengeS256Sync(verifier) {
1869
+ return base64url(sha256(new TextEncoder().encode(verifier)));
1870
+ }
1871
+ var K = new Uint32Array([
1872
+ 1116352408,
1873
+ 1899447441,
1874
+ 3049323471,
1875
+ 3921009573,
1876
+ 961987163,
1877
+ 1508970993,
1878
+ 2453635748,
1879
+ 2870763221,
1880
+ 3624381080,
1881
+ 310598401,
1882
+ 607225278,
1883
+ 1426881987,
1884
+ 1925078388,
1885
+ 2162078206,
1886
+ 2614888103,
1887
+ 3248222580,
1888
+ 3835390401,
1889
+ 4022224774,
1890
+ 264347078,
1891
+ 604807628,
1892
+ 770255983,
1893
+ 1249150122,
1894
+ 1555081692,
1895
+ 1996064986,
1896
+ 2554220882,
1897
+ 2821834349,
1898
+ 2952996808,
1899
+ 3210313671,
1900
+ 3336571891,
1901
+ 3584528711,
1902
+ 113926993,
1903
+ 338241895,
1904
+ 666307205,
1905
+ 773529912,
1906
+ 1294757372,
1907
+ 1396182291,
1908
+ 1695183700,
1909
+ 1986661051,
1910
+ 2177026350,
1911
+ 2456956037,
1912
+ 2730485921,
1913
+ 2820302411,
1914
+ 3259730800,
1915
+ 3345764771,
1916
+ 3516065817,
1917
+ 3600352804,
1918
+ 4094571909,
1919
+ 275423344,
1920
+ 430227734,
1921
+ 506948616,
1922
+ 659060556,
1923
+ 883997877,
1924
+ 958139571,
1925
+ 1322822218,
1926
+ 1537002063,
1927
+ 1747873779,
1928
+ 1955562222,
1929
+ 2024104815,
1930
+ 2227730452,
1931
+ 2361852424,
1932
+ 2428436474,
1933
+ 2756734187,
1934
+ 3204031479,
1935
+ 3329325298
1936
+ ]);
1937
+ var rotr = (x, n) => x >>> n | x << 32 - n;
1938
+ function sha256(message) {
1939
+ const H = new Uint32Array([
1940
+ 1779033703,
1941
+ 3144134277,
1942
+ 1013904242,
1943
+ 2773480762,
1944
+ 1359893119,
1945
+ 2600822924,
1946
+ 528734635,
1947
+ 1541459225
1948
+ ]);
1949
+ const length = message.length;
1950
+ const padded = new Uint8Array(length + 9 + 63 >> 6 << 6);
1951
+ padded.set(message);
1952
+ padded[length] = 128;
1953
+ const view = new DataView(padded.buffer);
1954
+ const bits = length * 8;
1955
+ view.setUint32(padded.length - 8, Math.floor(bits / 4294967296));
1956
+ view.setUint32(padded.length - 4, bits >>> 0);
1957
+ const W = new Uint32Array(64);
1958
+ for (let offset = 0; offset < padded.length; offset += 64) {
1959
+ for (let i = 0; i < 16; i++) W[i] = view.getUint32(offset + i * 4);
1960
+ for (let i = 16; i < 64; i++) {
1961
+ const w15 = W[i - 15];
1962
+ const w2 = W[i - 2];
1963
+ const s0 = rotr(w15, 7) ^ rotr(w15, 18) ^ w15 >>> 3;
1964
+ const s1 = rotr(w2, 17) ^ rotr(w2, 19) ^ w2 >>> 10;
1965
+ W[i] = W[i - 16] + s0 + W[i - 7] + s1 >>> 0;
1966
+ }
1967
+ let [a, b, c, d, e, f, g, h] = H;
1968
+ for (let i = 0; i < 64; i++) {
1969
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
1970
+ const ch = e & f ^ ~e & g;
1971
+ const t1 = h + S1 + ch + K[i] + W[i] >>> 0;
1972
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
1973
+ const maj = a & b ^ a & c ^ b & c;
1974
+ const t2 = S0 + maj >>> 0;
1975
+ h = g;
1976
+ g = f;
1977
+ f = e;
1978
+ e = d + t1 >>> 0;
1979
+ d = c;
1980
+ c = b;
1981
+ b = a;
1982
+ a = t1 + t2 >>> 0;
1983
+ }
1984
+ H[0] = H[0] + a >>> 0;
1985
+ H[1] = H[1] + b >>> 0;
1986
+ H[2] = H[2] + c >>> 0;
1987
+ H[3] = H[3] + d >>> 0;
1988
+ H[4] = H[4] + e >>> 0;
1989
+ H[5] = H[5] + f >>> 0;
1990
+ H[6] = H[6] + g >>> 0;
1991
+ H[7] = H[7] + h >>> 0;
1992
+ }
1993
+ const out = new Uint8Array(32);
1994
+ const outView = new DataView(out.buffer);
1995
+ for (let i = 0; i < 8; i++) outView.setUint32(i * 4, H[i]);
1996
+ return out;
1997
+ }
1831
1998
  function generateState() {
1832
1999
  const bytes = new Uint8Array(16);
1833
2000
  crypto.getRandomValues(bytes);
1834
2001
  return base64url(bytes);
1835
2002
  }
2003
+ var TOKEN_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
2004
+ function generateRequestId() {
2005
+ let out = "";
2006
+ const bytes = new Uint8Array(64);
2007
+ while (out.length < 32) {
2008
+ crypto.getRandomValues(bytes);
2009
+ for (const byte of bytes) {
2010
+ if (byte >= 248 || out.length === 32) continue;
2011
+ out += TOKEN_ALPHABET[byte % 62];
2012
+ }
2013
+ }
2014
+ return out;
2015
+ }
1836
2016
 
1837
2017
  // src/useZorealLogin.ts
1838
2018
  function useZorealFlow(options) {
@@ -1865,76 +2045,119 @@ function useZorealFlow(options) {
1865
2045
  const useAppLink = display === "link";
1866
2046
  const intent = resolveIntent(opts.intent, opts.scope, opts.acr_values);
1867
2047
  try {
1868
- const started = await startPairing(issuer, {
1869
- client_id: clientId,
1870
- scope: opts.scope ?? "openid",
1871
- state,
1872
- nonce,
1873
- code_challenge: await challengeS256(verifier),
1874
- redirect_uri: flow === "auth-code" ? opts.redirect_uri : void 0,
1875
- acr_values: Array.isArray(opts.acr_values) ? opts.acr_values.join(" ") : opts.acr_values,
1876
- max_age: opts.max_age,
1877
- prompt: opts.prompt,
1878
- locale,
1879
- display
1880
- });
1881
2048
  let code;
1882
2049
  let selectBy = "device";
1883
- if ("code" in started) {
1884
- code = started.code;
1885
- selectBy = "session";
1886
- } else {
1887
- selectBy = useAppLink ? "app_link" : "qr";
1888
- const qrRefreshSeconds = qrRefreshSecondsOf(started);
2050
+ if (useAppLink) {
2051
+ const requestId = generateRequestId();
2052
+ const startUrl = sameDeviceStartUrl(issuer, {
2053
+ client_id: clientId,
2054
+ scope: opts.scope ?? "openid",
2055
+ state,
2056
+ nonce,
2057
+ code_challenge: challengeS256Sync(verifier),
2058
+ redirect_uri: flow === "auth-code" ? opts.redirect_uri : void 0,
2059
+ acr_values: Array.isArray(opts.acr_values) ? opts.acr_values.join(" ") : opts.acr_values,
2060
+ max_age: opts.max_age,
2061
+ prompt: opts.prompt,
2062
+ locale,
2063
+ request_id: requestId,
2064
+ origin: window.location.origin
2065
+ });
2066
+ selectBy = "app_link";
1889
2067
  const cancel = () => {
1890
2068
  controller.abort();
1891
2069
  setPairing(null);
1892
- publishRef.current?.(null);
1893
2070
  };
1894
- const surface = {
1895
- pairUrl: started.pair_url,
1896
- appLink: useAppLink,
1897
- intent,
1898
- cancel,
1899
- // The app link has no QR and therefore no cadence to report.
1900
- ...useAppLink ? null : { qrRefreshSeconds }
1901
- };
1902
- let qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;
2071
+ const surface = { pairUrl: startUrl, appLink: true, intent, cancel };
1903
2072
  const active = {
1904
- requestId: started.request_id,
1905
- pairUrl: surface.pairUrl,
1906
- qrUrl,
1907
- state: { status: "pending", expiresIn: started.expires_in, qrUrl, ...surface },
1908
- appLink: useAppLink,
2073
+ requestId,
2074
+ pairUrl: startUrl,
2075
+ qrUrl: "",
2076
+ state: { status: "pending", ...surface },
2077
+ appLink: true,
1909
2078
  cancel
1910
2079
  };
1911
2080
  setPairing(active);
1912
- if (!useAppLink) {
1913
- publishRef.current?.({ state: active.state, qrUrl, intent, cancel });
1914
- }
1915
2081
  opts.onPairingStateChange?.(active.state);
1916
- if (useAppLink) {
1917
- window.location.assign(started.pair_url);
1918
- }
2082
+ window.location.assign(startUrl);
1919
2083
  code = await pollUntilApproved(
1920
2084
  issuer,
1921
- started.request_id,
2085
+ requestId,
1922
2086
  (s) => {
1923
- if (s.qrUrl) qrUrl = s.qrUrl;
1924
- const enriched = { ...s, ...surface, qrUrl };
1925
- setPairing(
1926
- (p) => p && p.requestId === started.request_id ? { ...p, qrUrl, state: enriched } : p
1927
- );
1928
- if (!useAppLink) {
1929
- publishRef.current?.({ state: enriched, qrUrl, intent, cancel });
1930
- }
2087
+ const enriched = { ...s, ...surface };
2088
+ setPairing((p) => p && p.requestId === requestId ? { ...p, state: enriched } : p);
1931
2089
  opts.onPairingStateChange?.(enriched);
1932
2090
  },
1933
2091
  controller.signal,
1934
- // The app link has no QR to animate, and the provider answers 404
1935
- // for one, so the frames are asked for only on the QR surface.
1936
- { qrRefreshSeconds: useAppLink ? void 0 : qrRefreshSeconds }
2092
+ { tolerateUnknownUntil: Date.now() + 15e3 }
1937
2093
  );
2094
+ } else {
2095
+ const started = await startPairing(issuer, {
2096
+ client_id: clientId,
2097
+ scope: opts.scope ?? "openid",
2098
+ state,
2099
+ nonce,
2100
+ code_challenge: await challengeS256(verifier),
2101
+ redirect_uri: flow === "auth-code" ? opts.redirect_uri : void 0,
2102
+ acr_values: Array.isArray(opts.acr_values) ? opts.acr_values.join(" ") : opts.acr_values,
2103
+ max_age: opts.max_age,
2104
+ prompt: opts.prompt,
2105
+ locale,
2106
+ display: "qr"
2107
+ });
2108
+ if ("code" in started) {
2109
+ code = started.code;
2110
+ selectBy = "session";
2111
+ } else {
2112
+ selectBy = "qr";
2113
+ const qrRefreshSeconds = qrRefreshSecondsOf(started);
2114
+ const cancel = () => {
2115
+ controller.abort();
2116
+ setPairing(null);
2117
+ publishRef.current?.(null);
2118
+ };
2119
+ const surface = {
2120
+ pairUrl: started.pair_url,
2121
+ appLink: false,
2122
+ intent,
2123
+ cancel,
2124
+ qrRefreshSeconds
2125
+ };
2126
+ let qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;
2127
+ const active = {
2128
+ requestId: started.request_id,
2129
+ pairUrl: surface.pairUrl,
2130
+ qrUrl,
2131
+ state: { status: "pending", expiresIn: started.expires_in, qrUrl, ...surface },
2132
+ appLink: false,
2133
+ cancel
2134
+ };
2135
+ setPairing(active);
2136
+ if (!useAppLink) {
2137
+ publishRef.current?.({ state: active.state, qrUrl, intent, cancel });
2138
+ }
2139
+ opts.onPairingStateChange?.(active.state);
2140
+ if (useAppLink) {
2141
+ window.location.assign(started.pair_url);
2142
+ }
2143
+ code = await pollUntilApproved(
2144
+ issuer,
2145
+ started.request_id,
2146
+ (s) => {
2147
+ if (s.qrUrl) qrUrl = s.qrUrl;
2148
+ const enriched = { ...s, ...surface, qrUrl };
2149
+ setPairing(
2150
+ (p) => p && p.requestId === started.request_id ? { ...p, qrUrl, state: enriched } : p
2151
+ );
2152
+ if (!useAppLink) {
2153
+ publishRef.current?.({ state: enriched, qrUrl, intent, cancel });
2154
+ }
2155
+ opts.onPairingStateChange?.(enriched);
2156
+ },
2157
+ controller.signal,
2158
+ { qrRefreshSeconds }
2159
+ );
2160
+ }
1938
2161
  }
1939
2162
  setPairing(null);
1940
2163
  publishRef.current?.(null);