@zoreal/oauth2-react 0.2.18 → 0.2.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
@@ -251,7 +251,7 @@ What the modal does:
251
251
 
252
252
  | | |
253
253
  | --- | --- |
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'`. |
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. Once the holder has approved, the app reopens your page with the pairing named in the fragment, and the first `useZorealLogin` or `ZorealLogin` on that page finishes the sign-in there, through the same `onSuccess` and `onError`; the tab that was left behind stands down when the returned page finishes first; 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. |
@@ -551,6 +551,7 @@ These reach your callbacks before any backend is involved — handle them here:
551
551
  | `/pair` | `onError` | `login_required` | `prompt: 'none'` with no silent session to resume — the expected quiet outcome, not a failure (`useZorealAutoLogin` turns it into `onUnavailable`) |
552
552
  | pairing | `onNonOAuthError` | `request_denied` | The holder declined in their ZOREAL ID app — **not an error to alarm on**; offer to try again |
553
553
  | pairing | `onNonOAuthError` | `request_expired` | The pairing window elapsed, or a required liveness the device could not meet — offer to try again |
554
+ | pairing | `onNonOAuthError` | `popup_closed` | The person closed the dialog, cancelled it, pressed Escape, tapped outside it, or let it time out — **not an error to alarm on**; clear your busy state and let them tap again |
554
555
 
555
556
  ### The type unions
556
557
 
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.18";
44
+ var SDK_VERSION = "0.2.19";
45
45
  var DEFAULT_ISSUER = "https://id.zoreal.com";
46
46
  var POLL_INTERVAL_MS = 2e3;
47
47
  var POLL_INTERVAL_ENROLLING_MS = 5e3;
@@ -1636,6 +1636,76 @@ var import_react5 = require("react");
1636
1636
  // src/useZorealLogin.ts
1637
1637
  var import_react3 = require("react");
1638
1638
 
1639
+ // src/return.ts
1640
+ var PREFIX2 = "zoreal:oauth2:return:";
1641
+ var DONE = "zoreal:oauth2:done:";
1642
+ var MAX_AGE_MS = 10 * 60 * 1e3;
1643
+ function storage() {
1644
+ try {
1645
+ return typeof localStorage === "undefined" ? null : localStorage;
1646
+ } catch {
1647
+ return null;
1648
+ }
1649
+ }
1650
+ function saveReturnFlow(flow) {
1651
+ try {
1652
+ storage()?.setItem(PREFIX2 + flow.requestId, JSON.stringify(flow));
1653
+ } catch {
1654
+ }
1655
+ }
1656
+ function peekReturnFlow(requestId) {
1657
+ const store = storage();
1658
+ if (!store) return null;
1659
+ const raw = store.getItem(PREFIX2 + requestId);
1660
+ if (!raw) return null;
1661
+ try {
1662
+ const flow = JSON.parse(raw);
1663
+ if (flow.v !== 1 || flow.requestId !== requestId) return null;
1664
+ if (Date.now() - flow.createdAt > MAX_AGE_MS) return null;
1665
+ return flow;
1666
+ } catch {
1667
+ return null;
1668
+ }
1669
+ }
1670
+ function forgetReturnFlow(requestId) {
1671
+ try {
1672
+ storage()?.removeItem(PREFIX2 + requestId);
1673
+ } catch {
1674
+ }
1675
+ if (pending === requestId) pending = null;
1676
+ }
1677
+ function markReturnDone(requestId) {
1678
+ try {
1679
+ const store = storage();
1680
+ store?.setItem(DONE + requestId, String(Date.now()));
1681
+ store?.removeItem(PREFIX2 + requestId);
1682
+ } catch {
1683
+ }
1684
+ }
1685
+ function isReturnDone(requestId) {
1686
+ return storage()?.getItem(DONE + requestId) !== null && storage()?.getItem(DONE + requestId) !== void 0;
1687
+ }
1688
+ function returnToUrl() {
1689
+ if (typeof window === "undefined") return void 0;
1690
+ const { href } = window.location;
1691
+ const hash = href.indexOf("#");
1692
+ return hash === -1 ? href : href.slice(0, hash);
1693
+ }
1694
+ var RETURN_MARK = /(?:^|[#&])zoreal_return=([A-Za-z0-9]{32})(?:&|$)/;
1695
+ var pending = null;
1696
+ function pendingReturnId() {
1697
+ if (pending) return pending;
1698
+ if (typeof window === "undefined") return null;
1699
+ const match = RETURN_MARK.exec(window.location.hash);
1700
+ if (!match) return null;
1701
+ pending = match[1];
1702
+ try {
1703
+ window.history.replaceState(window.history.state, "", returnToUrl());
1704
+ } catch {
1705
+ }
1706
+ return pending;
1707
+ }
1708
+
1639
1709
  // src/jwt.ts
1640
1710
  function unsafeClaims(idToken) {
1641
1711
  try {
@@ -2015,6 +2085,7 @@ function generateRequestId() {
2015
2085
  }
2016
2086
 
2017
2087
  // src/useZorealLogin.ts
2088
+ var resumedReturns = /* @__PURE__ */ new Set();
2018
2089
  function useZorealFlow(options) {
2019
2090
  const { clientId, issuer, locale } = useZorealOAuth();
2020
2091
  const [pairing, setPairing] = (0, import_react3.useState)(null);
@@ -2024,6 +2095,7 @@ function useZorealFlow(options) {
2024
2095
  const abortRef = (0, import_react3.useRef)(null);
2025
2096
  const optionsRef = (0, import_react3.useRef)(options);
2026
2097
  optionsRef.current = options;
2098
+ const closedByPerson = (0, import_react3.useRef)(false);
2027
2099
  (0, import_react3.useEffect)(
2028
2100
  () => () => {
2029
2101
  abortRef.current?.abort();
@@ -2031,6 +2103,61 @@ function useZorealFlow(options) {
2031
2103
  },
2032
2104
  []
2033
2105
  );
2106
+ (0, import_react3.useEffect)(() => {
2107
+ const id = pendingReturnId();
2108
+ if (!id || resumedReturns.has(id)) return;
2109
+ const saved = peekReturnFlow(id);
2110
+ if (!saved || saved.clientId !== clientId) return;
2111
+ forgetReturnFlow(id);
2112
+ resumedReturns.add(id);
2113
+ const controller = new AbortController();
2114
+ abortRef.current = controller;
2115
+ void (async () => {
2116
+ const opts = optionsRef.current;
2117
+ try {
2118
+ const code = await pollUntilApproved(issuer, id, void 0, controller.signal, {
2119
+ tolerateUnknownUntil: Date.now() + 5e3
2120
+ });
2121
+ if (saved.flow === "auth-code") {
2122
+ opts.onCode?.({
2123
+ code,
2124
+ scope: saved.scope,
2125
+ app_state: saved.appState,
2126
+ code_verifier: saved.verifier,
2127
+ nonce: saved.nonce
2128
+ });
2129
+ } else {
2130
+ const tokens = await exchangeCode(issuer, {
2131
+ code,
2132
+ code_verifier: saved.verifier,
2133
+ client_id: clientId
2134
+ });
2135
+ const claims = unsafeClaims(tokens.id_token);
2136
+ opts.onCredential?.({
2137
+ credential: tokens.id_token,
2138
+ clientId,
2139
+ select_by: "app_link",
2140
+ acr: claims.acr ?? "zoreal.device"
2141
+ });
2142
+ }
2143
+ markReturnDone(id);
2144
+ } catch (e) {
2145
+ if (e instanceof DOMException && e.name === "AbortError") return;
2146
+ if (e instanceof FlowAbandonedError) {
2147
+ opts.onNonOAuthError?.(e.reason);
2148
+ return;
2149
+ }
2150
+ if (e instanceof OAuthFlowError) {
2151
+ opts.onError?.({ error: e.error, description: e.description });
2152
+ return;
2153
+ }
2154
+ opts.onNonOAuthError?.({
2155
+ type: "unknown",
2156
+ description: e instanceof Error ? e.message : String(e)
2157
+ });
2158
+ }
2159
+ })();
2160
+ }, [clientId, issuer]);
2034
2161
  const login = (0, import_react3.useCallback)(() => {
2035
2162
  const opts = optionsRef.current;
2036
2163
  const run = async () => {
@@ -2047,8 +2174,23 @@ function useZorealFlow(options) {
2047
2174
  try {
2048
2175
  let code;
2049
2176
  let selectBy = "device";
2177
+ let returnId = null;
2050
2178
  if (useAppLink) {
2051
2179
  const requestId = generateRequestId();
2180
+ saveReturnFlow({
2181
+ v: 1,
2182
+ issuer,
2183
+ clientId,
2184
+ flow,
2185
+ verifier,
2186
+ nonce,
2187
+ state,
2188
+ scope: opts.scope ?? "openid",
2189
+ appState: opts.app_state,
2190
+ requestId,
2191
+ createdAt: Date.now()
2192
+ });
2193
+ returnId = requestId;
2052
2194
  const startUrl = sameDeviceStartUrl(issuer, {
2053
2195
  client_id: clientId,
2054
2196
  scope: opts.scope ?? "openid",
@@ -2061,10 +2203,12 @@ function useZorealFlow(options) {
2061
2203
  prompt: opts.prompt,
2062
2204
  locale,
2063
2205
  request_id: requestId,
2064
- origin: window.location.origin
2206
+ origin: window.location.origin,
2207
+ return_to: returnToUrl()
2065
2208
  });
2066
2209
  selectBy = "app_link";
2067
2210
  const cancel = () => {
2211
+ closedByPerson.current = true;
2068
2212
  controller.abort();
2069
2213
  setPairing(null);
2070
2214
  };
@@ -2091,6 +2235,9 @@ function useZorealFlow(options) {
2091
2235
  controller.signal,
2092
2236
  { tolerateUnknownUntil: Date.now() + 15e3 }
2093
2237
  );
2238
+ if (isReturnDone(requestId)) {
2239
+ throw new DOMException("aborted", "AbortError");
2240
+ }
2094
2241
  } else {
2095
2242
  const started = await startPairing(issuer, {
2096
2243
  client_id: clientId,
@@ -2112,6 +2259,7 @@ function useZorealFlow(options) {
2112
2259
  selectBy = "qr";
2113
2260
  const qrRefreshSeconds = qrRefreshSecondsOf(started);
2114
2261
  const cancel = () => {
2262
+ closedByPerson.current = true;
2115
2263
  controller.abort();
2116
2264
  setPairing(null);
2117
2265
  publishRef.current?.(null);
@@ -2161,6 +2309,7 @@ function useZorealFlow(options) {
2161
2309
  }
2162
2310
  setPairing(null);
2163
2311
  publishRef.current?.(null);
2312
+ if (returnId) markReturnDone(returnId);
2164
2313
  if (flow === "auth-code") {
2165
2314
  opts.onCode?.({
2166
2315
  code,
@@ -2187,7 +2336,16 @@ function useZorealFlow(options) {
2187
2336
  } catch (e) {
2188
2337
  setPairing(null);
2189
2338
  publishRef.current?.(null);
2190
- if (e instanceof DOMException && e.name === "AbortError") return;
2339
+ if (e instanceof DOMException && e.name === "AbortError") {
2340
+ if (closedByPerson.current) {
2341
+ closedByPerson.current = false;
2342
+ opts.onNonOAuthError?.({
2343
+ type: "popup_closed",
2344
+ description: "the sign-in dialog was closed before the holder approved"
2345
+ });
2346
+ }
2347
+ return;
2348
+ }
2191
2349
  if (e instanceof FlowAbandonedError) {
2192
2350
  opts.onNonOAuthError?.(e.reason);
2193
2351
  return;