@zoreal/oauth2-js 0.1.17 → 0.1.19

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. `startLogin` 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, so disable the button you were tapped on 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. 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
@@ -60,7 +60,7 @@ function unsafeClaims(idToken) {
60
60
 
61
61
  // src/wire.ts
62
62
  var WIRE_VERSION = 1;
63
- var SDK_VERSION = "0.1.17";
63
+ var SDK_VERSION = "0.1.19";
64
64
  var SDK_NAME = "@zoreal/oauth2-js";
65
65
  var DEFAULT_ISSUER = "https://id.zoreal.com";
66
66
  var POLL_INTERVAL_MS = 2e3;
@@ -109,6 +109,20 @@ async function startPairing(issuer, params, signal) {
109
109
  }
110
110
  return body;
111
111
  }
112
+ function sameDeviceStartUrl(issuer, params) {
113
+ const query = new URLSearchParams();
114
+ const all = {
115
+ ...params,
116
+ code_challenge_method: "S256",
117
+ wire_version: WIRE_VERSION,
118
+ sdk: `${SDK_NAME}/${SDK_VERSION}`
119
+ };
120
+ for (const [key, value] of Object.entries(all)) {
121
+ if (value === void 0 || value === null || value === "") continue;
122
+ query.set(key, String(value));
123
+ }
124
+ return `${issuer}/pair/start?${query.toString()}`;
125
+ }
112
126
  var sleep = (ms, signal) => new Promise((resolve, reject) => {
113
127
  if (signal?.aborted) {
114
128
  reject(new DOMException("aborted", "AbortError"));
@@ -124,23 +138,48 @@ var sleep = (ms, signal) => new Promise((resolve, reject) => {
124
138
  }, ms);
125
139
  signal?.addEventListener("abort", onAbort, { once: true });
126
140
  });
127
- async function pollUntilApproved(issuer, requestId, onState, signal) {
141
+ var SETTLE_MS = 1500;
142
+ var NETWORK_FAILURES_TOLERATED = 4;
143
+ async function pollUntilApproved(issuer, requestId, onState, signal, options = {}) {
144
+ const settlingUntil = options.tolerateUnknownUntil ?? 0;
145
+ let networkFailures = 0;
146
+ let last = { status: "pending" };
147
+ if (settlingUntil > Date.now()) await sleep(SETTLE_MS, signal);
128
148
  for (; ; ) {
129
- const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
130
- signal
131
- });
149
+ let response;
150
+ try {
151
+ response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
152
+ signal
153
+ });
154
+ networkFailures = 0;
155
+ } catch (e) {
156
+ if (e instanceof DOMException && e.name === "AbortError") throw e;
157
+ networkFailures += 1;
158
+ if (settlingUntil > Date.now() || networkFailures <= NETWORK_FAILURES_TOLERATED) {
159
+ onState?.(last);
160
+ await sleep(POLL_INTERVAL_MS, signal);
161
+ continue;
162
+ }
163
+ throw e;
164
+ }
132
165
  const body = await parseJson(response);
166
+ if (response.status === 404 && (options.tolerateUnknownUntil ?? 0) > Date.now()) {
167
+ onState?.({ status: "pending" });
168
+ await sleep(POLL_INTERVAL_MS, signal);
169
+ continue;
170
+ }
133
171
  if (!response.ok) {
134
172
  throw new OAuthFlowError(
135
173
  body.error ?? "server_error",
136
174
  body.error_description ?? `Pairing status failed (${response.status})`
137
175
  );
138
176
  }
139
- onState?.({
177
+ last = {
140
178
  status: body.status,
141
179
  expiresIn: body.expires_in,
142
180
  enrolmentDeadline: body.enrolment_deadline
143
- });
181
+ };
182
+ onState?.(last);
144
183
  switch (body.status) {
145
184
  case "approved":
146
185
  if (!body.code) {
@@ -1734,11 +1773,154 @@ async function challengeS256(verifier) {
1734
1773
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
1735
1774
  return base64url(new Uint8Array(digest));
1736
1775
  }
1776
+ function challengeS256Sync(verifier) {
1777
+ return base64url(sha256(new TextEncoder().encode(verifier)));
1778
+ }
1779
+ var K = new Uint32Array([
1780
+ 1116352408,
1781
+ 1899447441,
1782
+ 3049323471,
1783
+ 3921009573,
1784
+ 961987163,
1785
+ 1508970993,
1786
+ 2453635748,
1787
+ 2870763221,
1788
+ 3624381080,
1789
+ 310598401,
1790
+ 607225278,
1791
+ 1426881987,
1792
+ 1925078388,
1793
+ 2162078206,
1794
+ 2614888103,
1795
+ 3248222580,
1796
+ 3835390401,
1797
+ 4022224774,
1798
+ 264347078,
1799
+ 604807628,
1800
+ 770255983,
1801
+ 1249150122,
1802
+ 1555081692,
1803
+ 1996064986,
1804
+ 2554220882,
1805
+ 2821834349,
1806
+ 2952996808,
1807
+ 3210313671,
1808
+ 3336571891,
1809
+ 3584528711,
1810
+ 113926993,
1811
+ 338241895,
1812
+ 666307205,
1813
+ 773529912,
1814
+ 1294757372,
1815
+ 1396182291,
1816
+ 1695183700,
1817
+ 1986661051,
1818
+ 2177026350,
1819
+ 2456956037,
1820
+ 2730485921,
1821
+ 2820302411,
1822
+ 3259730800,
1823
+ 3345764771,
1824
+ 3516065817,
1825
+ 3600352804,
1826
+ 4094571909,
1827
+ 275423344,
1828
+ 430227734,
1829
+ 506948616,
1830
+ 659060556,
1831
+ 883997877,
1832
+ 958139571,
1833
+ 1322822218,
1834
+ 1537002063,
1835
+ 1747873779,
1836
+ 1955562222,
1837
+ 2024104815,
1838
+ 2227730452,
1839
+ 2361852424,
1840
+ 2428436474,
1841
+ 2756734187,
1842
+ 3204031479,
1843
+ 3329325298
1844
+ ]);
1845
+ var rotr = (x, n) => x >>> n | x << 32 - n;
1846
+ function sha256(message) {
1847
+ const H = new Uint32Array([
1848
+ 1779033703,
1849
+ 3144134277,
1850
+ 1013904242,
1851
+ 2773480762,
1852
+ 1359893119,
1853
+ 2600822924,
1854
+ 528734635,
1855
+ 1541459225
1856
+ ]);
1857
+ const length = message.length;
1858
+ const padded = new Uint8Array(length + 9 + 63 >> 6 << 6);
1859
+ padded.set(message);
1860
+ padded[length] = 128;
1861
+ const view = new DataView(padded.buffer);
1862
+ const bits = length * 8;
1863
+ view.setUint32(padded.length - 8, Math.floor(bits / 4294967296));
1864
+ view.setUint32(padded.length - 4, bits >>> 0);
1865
+ const W = new Uint32Array(64);
1866
+ for (let offset = 0; offset < padded.length; offset += 64) {
1867
+ for (let i = 0; i < 16; i++) W[i] = view.getUint32(offset + i * 4);
1868
+ for (let i = 16; i < 64; i++) {
1869
+ const w15 = W[i - 15];
1870
+ const w2 = W[i - 2];
1871
+ const s0 = rotr(w15, 7) ^ rotr(w15, 18) ^ w15 >>> 3;
1872
+ const s1 = rotr(w2, 17) ^ rotr(w2, 19) ^ w2 >>> 10;
1873
+ W[i] = W[i - 16] + s0 + W[i - 7] + s1 >>> 0;
1874
+ }
1875
+ let [a, b, c, d, e, f, g, h] = H;
1876
+ for (let i = 0; i < 64; i++) {
1877
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
1878
+ const ch = e & f ^ ~e & g;
1879
+ const t1 = h + S1 + ch + K[i] + W[i] >>> 0;
1880
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
1881
+ const maj = a & b ^ a & c ^ b & c;
1882
+ const t2 = S0 + maj >>> 0;
1883
+ h = g;
1884
+ g = f;
1885
+ f = e;
1886
+ e = d + t1 >>> 0;
1887
+ d = c;
1888
+ c = b;
1889
+ b = a;
1890
+ a = t1 + t2 >>> 0;
1891
+ }
1892
+ H[0] = H[0] + a >>> 0;
1893
+ H[1] = H[1] + b >>> 0;
1894
+ H[2] = H[2] + c >>> 0;
1895
+ H[3] = H[3] + d >>> 0;
1896
+ H[4] = H[4] + e >>> 0;
1897
+ H[5] = H[5] + f >>> 0;
1898
+ H[6] = H[6] + g >>> 0;
1899
+ H[7] = H[7] + h >>> 0;
1900
+ }
1901
+ const out = new Uint8Array(32);
1902
+ const outView = new DataView(out.buffer);
1903
+ for (let i = 0; i < 8; i++) outView.setUint32(i * 4, H[i]);
1904
+ return out;
1905
+ }
1737
1906
  function generateState() {
1738
1907
  const bytes = new Uint8Array(16);
1739
1908
  crypto.getRandomValues(bytes);
1740
1909
  return base64url(bytes);
1741
1910
  }
1911
+ var TOKEN_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
1912
+ function generateRequestId() {
1913
+ let out = "";
1914
+ const bytes = new Uint8Array(64);
1915
+ while (out.length < 32) {
1916
+ crypto.getRandomValues(bytes);
1917
+ for (const byte of bytes) {
1918
+ if (byte >= 248 || out.length === 32) continue;
1919
+ out += TOKEN_ALPHABET[byte % 62];
1920
+ }
1921
+ }
1922
+ return out;
1923
+ }
1742
1924
 
1743
1925
  // src/login.ts
1744
1926
  function startLogin(options) {
@@ -1768,109 +1950,144 @@ function startLogin(options) {
1768
1950
  const state = generateState();
1769
1951
  const nonce = generateState();
1770
1952
  try {
1771
- const started = await startPairing(
1772
- issuer,
1773
- {
1953
+ let code;
1954
+ let selectBy = "device";
1955
+ if (useAppLink && typeof window !== "undefined") {
1956
+ const requestId = generateRequestId();
1957
+ const startUrl = sameDeviceStartUrl(issuer, {
1774
1958
  client_id: options.clientId,
1775
1959
  scope: options.scope ?? "openid",
1776
1960
  state,
1777
1961
  nonce,
1778
- code_challenge: await challengeS256(verifier),
1962
+ code_challenge: challengeS256Sync(verifier),
1779
1963
  redirect_uri: flow === "auth-code" ? options.redirect_uri : void 0,
1780
1964
  acr_values: Array.isArray(options.acr_values) ? options.acr_values.join(" ") : options.acr_values,
1781
1965
  max_age: options.max_age,
1782
1966
  prompt: options.prompt,
1783
1967
  locale: options.locale,
1784
- display: useAppLink ? "link" : "qr"
1785
- },
1786
- controller.signal
1787
- );
1788
- let code;
1789
- let selectBy = "device";
1790
- if ("code" in started) {
1791
- code = started.code;
1792
- selectBy = "session";
1793
- } else {
1794
- selectBy = useAppLink ? "app_link" : "qr";
1795
- const requestId = started.request_id;
1796
- const qrBase = `${issuer}/pair/${encodeURIComponent(requestId)}/qr.svg`;
1797
- const animated = !useAppLink && started.display !== "legacy";
1798
- const qrRefreshSeconds = !animated ? void 0 : typeof started.qr_refresh_seconds === "number" && started.qr_refresh_seconds > 0 ? started.qr_refresh_seconds : DEFAULT_QR_REFRESH_SECONDS;
1968
+ request_id: requestId,
1969
+ origin: window.location.origin
1970
+ });
1971
+ selectBy = "app_link";
1799
1972
  surface.requestId = requestId;
1800
- surface.pairUrl = started.pair_url;
1801
- surface.qrUrl = qrBase;
1802
- surface.appLink = useAppLink;
1973
+ surface.pairUrl = startUrl;
1974
+ surface.appLink = true;
1803
1975
  const withSurface = (s) => ({
1804
1976
  ...s,
1805
- pairUrl: surface.pairUrl,
1806
- qrUrl: surface.qrUrl,
1807
- qrRefreshSeconds,
1808
- appLink: useAppLink,
1977
+ pairUrl: startUrl,
1978
+ appLink: true,
1809
1979
  intent,
1810
1980
  cancel
1811
1981
  });
1812
- let lastPolled = { status: "pending", expiresIn: started.expires_in };
1813
- const initial = withSurface(lastPolled);
1814
- options.onState?.(initial);
1815
- if ((options.pairingUI ?? "modal") === "modal" && !useAppLink) {
1816
- modal = mountPairingModal(initial, {
1817
- onCancel: cancel,
1818
- intent,
1819
- locale: options.locale,
1820
- theme: options.theme,
1821
- timeoutMs: options.pairingTimeoutMs
1822
- });
1823
- }
1824
- if (useAppLink && typeof window !== "undefined") {
1825
- window.location.assign(started.pair_url);
1826
- }
1827
- if (qrRefreshSeconds !== void 0) {
1828
- const periodMs = qrRefreshSeconds * 1e3;
1829
- let due = Date.now() + periodMs;
1830
- let timer;
1831
- let stopped = false;
1832
- const emit = () => {
1833
- timer = void 0;
1834
- surface.qrUrl = `${qrBase}?t=${Date.now()}`;
1835
- const next = withSurface(lastPolled);
1836
- modal?.update(next);
1837
- options.onState?.(next);
1838
- if (stopped) return;
1839
- due = Date.now() + periodMs;
1840
- timer = setTimeout(emit, periodMs);
1841
- };
1842
- const onVisible = () => {
1843
- if (document.visibilityState === "visible" && timer !== void 0 && Date.now() >= due) {
1844
- clearTimeout(timer);
1845
- emit();
1846
- }
1847
- };
1848
- const hasDocument = typeof document !== "undefined";
1849
- if (hasDocument) document.addEventListener("visibilitychange", onVisible);
1850
- stopRefresh = () => {
1851
- stopped = true;
1852
- if (timer !== void 0) clearTimeout(timer);
1853
- timer = void 0;
1854
- if (hasDocument) document.removeEventListener("visibilitychange", onVisible);
1855
- stopRefresh = () => {
1856
- };
1857
- };
1858
- timer = setTimeout(emit, Math.max(0, due - Date.now()));
1859
- }
1982
+ options.onState?.(withSurface({ status: "pending" }));
1983
+ window.location.assign(startUrl);
1860
1984
  code = await pollUntilApproved(
1861
1985
  issuer,
1862
1986
  requestId,
1863
- (s) => {
1864
- lastPolled = s;
1865
- if (s.status !== "pending") stopRefresh();
1866
- const next = withSurface(s);
1867
- modal?.update(next);
1868
- options.onState?.(next);
1987
+ (s) => options.onState?.(withSurface(s)),
1988
+ controller.signal,
1989
+ { tolerateUnknownUntil: Date.now() + 15e3 }
1990
+ );
1991
+ } else {
1992
+ const started = await startPairing(
1993
+ issuer,
1994
+ {
1995
+ client_id: options.clientId,
1996
+ scope: options.scope ?? "openid",
1997
+ state,
1998
+ nonce,
1999
+ code_challenge: await challengeS256(verifier),
2000
+ redirect_uri: flow === "auth-code" ? options.redirect_uri : void 0,
2001
+ acr_values: Array.isArray(options.acr_values) ? options.acr_values.join(" ") : options.acr_values,
2002
+ max_age: options.max_age,
2003
+ prompt: options.prompt,
2004
+ locale: options.locale,
2005
+ display: "qr"
1869
2006
  },
1870
2007
  controller.signal
1871
2008
  );
2009
+ if ("code" in started) {
2010
+ code = started.code;
2011
+ selectBy = "session";
2012
+ } else {
2013
+ selectBy = "qr";
2014
+ const requestId = started.request_id;
2015
+ const qrBase = `${issuer}/pair/${encodeURIComponent(requestId)}/qr.svg`;
2016
+ const animated = started.display !== "legacy";
2017
+ const qrRefreshSeconds = !animated ? void 0 : typeof started.qr_refresh_seconds === "number" && started.qr_refresh_seconds > 0 ? started.qr_refresh_seconds : DEFAULT_QR_REFRESH_SECONDS;
2018
+ surface.requestId = requestId;
2019
+ surface.pairUrl = started.pair_url;
2020
+ surface.qrUrl = qrBase;
2021
+ surface.appLink = false;
2022
+ const withSurface = (s) => ({
2023
+ ...s,
2024
+ pairUrl: surface.pairUrl,
2025
+ qrUrl: surface.qrUrl,
2026
+ qrRefreshSeconds,
2027
+ appLink: false,
2028
+ intent,
2029
+ cancel
2030
+ });
2031
+ let lastPolled = { status: "pending", expiresIn: started.expires_in };
2032
+ const initial = withSurface(lastPolled);
2033
+ options.onState?.(initial);
2034
+ if ((options.pairingUI ?? "modal") === "modal") {
2035
+ modal = mountPairingModal(initial, {
2036
+ onCancel: cancel,
2037
+ intent,
2038
+ locale: options.locale,
2039
+ theme: options.theme,
2040
+ timeoutMs: options.pairingTimeoutMs
2041
+ });
2042
+ }
2043
+ if (qrRefreshSeconds !== void 0) {
2044
+ const periodMs = qrRefreshSeconds * 1e3;
2045
+ let due = Date.now() + periodMs;
2046
+ let timer;
2047
+ let stopped = false;
2048
+ const emit = () => {
2049
+ timer = void 0;
2050
+ surface.qrUrl = `${qrBase}?t=${Date.now()}`;
2051
+ const next = withSurface(lastPolled);
2052
+ modal?.update(next);
2053
+ options.onState?.(next);
2054
+ if (stopped) return;
2055
+ due = Date.now() + periodMs;
2056
+ timer = setTimeout(emit, periodMs);
2057
+ };
2058
+ const onVisible = () => {
2059
+ if (document.visibilityState === "visible" && timer !== void 0 && Date.now() >= due) {
2060
+ clearTimeout(timer);
2061
+ emit();
2062
+ }
2063
+ };
2064
+ const hasDocument = typeof document !== "undefined";
2065
+ if (hasDocument) document.addEventListener("visibilitychange", onVisible);
2066
+ stopRefresh = () => {
2067
+ stopped = true;
2068
+ if (timer !== void 0) clearTimeout(timer);
2069
+ timer = void 0;
2070
+ if (hasDocument) document.removeEventListener("visibilitychange", onVisible);
2071
+ stopRefresh = () => {
2072
+ };
2073
+ };
2074
+ timer = setTimeout(emit, Math.max(0, due - Date.now()));
2075
+ }
2076
+ code = await pollUntilApproved(
2077
+ issuer,
2078
+ requestId,
2079
+ (s) => {
2080
+ lastPolled = s;
2081
+ if (s.status !== "pending") stopRefresh();
2082
+ const next = withSurface(s);
2083
+ modal?.update(next);
2084
+ options.onState?.(next);
2085
+ },
2086
+ controller.signal
2087
+ );
2088
+ }
2089
+ teardown();
1872
2090
  }
1873
- teardown();
1874
2091
  if (flow === "auth-code") {
1875
2092
  const response2 = {
1876
2093
  code,