@zoreal/oauth2-react 0.2.17 → 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 +2 -1
- package/dist/index.cjs +182 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +182 -6
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
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.
|
|
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 {
|
|
@@ -1727,6 +1797,8 @@ var sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
|
1727
1797
|
}, ms);
|
|
1728
1798
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1729
1799
|
});
|
|
1800
|
+
var SETTLE_MS = 1500;
|
|
1801
|
+
var NETWORK_FAILURES_TOLERATED = 4;
|
|
1730
1802
|
async function pollUntilApproved(issuer, requestId, onState, signal, options = {}) {
|
|
1731
1803
|
let last = { status: "pending" };
|
|
1732
1804
|
const emit = (state) => {
|
|
@@ -1755,11 +1827,27 @@ async function pollUntilApproved(issuer, requestId, onState, signal, options = {
|
|
|
1755
1827
|
})().catch(() => {
|
|
1756
1828
|
});
|
|
1757
1829
|
};
|
|
1830
|
+
const settlingUntil = options.tolerateUnknownUntil ?? 0;
|
|
1831
|
+
let networkFailures = 0;
|
|
1758
1832
|
try {
|
|
1833
|
+
if (settlingUntil > Date.now()) await sleep(SETTLE_MS, signal);
|
|
1759
1834
|
for (; ; ) {
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1835
|
+
let response;
|
|
1836
|
+
try {
|
|
1837
|
+
response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
|
|
1838
|
+
signal
|
|
1839
|
+
});
|
|
1840
|
+
networkFailures = 0;
|
|
1841
|
+
} catch (e) {
|
|
1842
|
+
if (e instanceof DOMException && e.name === "AbortError") throw e;
|
|
1843
|
+
networkFailures += 1;
|
|
1844
|
+
if (settlingUntil > Date.now() || networkFailures <= NETWORK_FAILURES_TOLERATED) {
|
|
1845
|
+
emit({ ...last, status: last.status });
|
|
1846
|
+
await sleep(POLL_INTERVAL_MS, signal);
|
|
1847
|
+
continue;
|
|
1848
|
+
}
|
|
1849
|
+
throw e;
|
|
1850
|
+
}
|
|
1763
1851
|
const body = await parseJson(response);
|
|
1764
1852
|
if (response.status === 404 && (options.tolerateUnknownUntil ?? 0) > Date.now()) {
|
|
1765
1853
|
emit({ status: "pending" });
|
|
@@ -1997,6 +2085,7 @@ function generateRequestId() {
|
|
|
1997
2085
|
}
|
|
1998
2086
|
|
|
1999
2087
|
// src/useZorealLogin.ts
|
|
2088
|
+
var resumedReturns = /* @__PURE__ */ new Set();
|
|
2000
2089
|
function useZorealFlow(options) {
|
|
2001
2090
|
const { clientId, issuer, locale } = useZorealOAuth();
|
|
2002
2091
|
const [pairing, setPairing] = (0, import_react3.useState)(null);
|
|
@@ -2006,6 +2095,7 @@ function useZorealFlow(options) {
|
|
|
2006
2095
|
const abortRef = (0, import_react3.useRef)(null);
|
|
2007
2096
|
const optionsRef = (0, import_react3.useRef)(options);
|
|
2008
2097
|
optionsRef.current = options;
|
|
2098
|
+
const closedByPerson = (0, import_react3.useRef)(false);
|
|
2009
2099
|
(0, import_react3.useEffect)(
|
|
2010
2100
|
() => () => {
|
|
2011
2101
|
abortRef.current?.abort();
|
|
@@ -2013,6 +2103,61 @@ function useZorealFlow(options) {
|
|
|
2013
2103
|
},
|
|
2014
2104
|
[]
|
|
2015
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]);
|
|
2016
2161
|
const login = (0, import_react3.useCallback)(() => {
|
|
2017
2162
|
const opts = optionsRef.current;
|
|
2018
2163
|
const run = async () => {
|
|
@@ -2029,8 +2174,23 @@ function useZorealFlow(options) {
|
|
|
2029
2174
|
try {
|
|
2030
2175
|
let code;
|
|
2031
2176
|
let selectBy = "device";
|
|
2177
|
+
let returnId = null;
|
|
2032
2178
|
if (useAppLink) {
|
|
2033
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;
|
|
2034
2194
|
const startUrl = sameDeviceStartUrl(issuer, {
|
|
2035
2195
|
client_id: clientId,
|
|
2036
2196
|
scope: opts.scope ?? "openid",
|
|
@@ -2043,10 +2203,12 @@ function useZorealFlow(options) {
|
|
|
2043
2203
|
prompt: opts.prompt,
|
|
2044
2204
|
locale,
|
|
2045
2205
|
request_id: requestId,
|
|
2046
|
-
origin: window.location.origin
|
|
2206
|
+
origin: window.location.origin,
|
|
2207
|
+
return_to: returnToUrl()
|
|
2047
2208
|
});
|
|
2048
2209
|
selectBy = "app_link";
|
|
2049
2210
|
const cancel = () => {
|
|
2211
|
+
closedByPerson.current = true;
|
|
2050
2212
|
controller.abort();
|
|
2051
2213
|
setPairing(null);
|
|
2052
2214
|
};
|
|
@@ -2073,6 +2235,9 @@ function useZorealFlow(options) {
|
|
|
2073
2235
|
controller.signal,
|
|
2074
2236
|
{ tolerateUnknownUntil: Date.now() + 15e3 }
|
|
2075
2237
|
);
|
|
2238
|
+
if (isReturnDone(requestId)) {
|
|
2239
|
+
throw new DOMException("aborted", "AbortError");
|
|
2240
|
+
}
|
|
2076
2241
|
} else {
|
|
2077
2242
|
const started = await startPairing(issuer, {
|
|
2078
2243
|
client_id: clientId,
|
|
@@ -2094,6 +2259,7 @@ function useZorealFlow(options) {
|
|
|
2094
2259
|
selectBy = "qr";
|
|
2095
2260
|
const qrRefreshSeconds = qrRefreshSecondsOf(started);
|
|
2096
2261
|
const cancel = () => {
|
|
2262
|
+
closedByPerson.current = true;
|
|
2097
2263
|
controller.abort();
|
|
2098
2264
|
setPairing(null);
|
|
2099
2265
|
publishRef.current?.(null);
|
|
@@ -2143,6 +2309,7 @@ function useZorealFlow(options) {
|
|
|
2143
2309
|
}
|
|
2144
2310
|
setPairing(null);
|
|
2145
2311
|
publishRef.current?.(null);
|
|
2312
|
+
if (returnId) markReturnDone(returnId);
|
|
2146
2313
|
if (flow === "auth-code") {
|
|
2147
2314
|
opts.onCode?.({
|
|
2148
2315
|
code,
|
|
@@ -2169,7 +2336,16 @@ function useZorealFlow(options) {
|
|
|
2169
2336
|
} catch (e) {
|
|
2170
2337
|
setPairing(null);
|
|
2171
2338
|
publishRef.current?.(null);
|
|
2172
|
-
if (e instanceof DOMException && e.name === "AbortError")
|
|
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
|
+
}
|
|
2173
2349
|
if (e instanceof FlowAbandonedError) {
|
|
2174
2350
|
opts.onNonOAuthError?.(e.reason);
|
|
2175
2351
|
return;
|