@zoreal/oauth2-react 0.2.18 → 0.2.20
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 +86 -42
- package/dist/index.cjs +163 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.js +162 -3
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -89,41 +89,76 @@ import { ZorealOAuthProvider } from '@zoreal/oauth2-react';
|
|
|
89
89
|
```
|
|
90
90
|
|
|
91
91
|
```tsx
|
|
92
|
-
// ZorealSignIn.tsx
|
|
93
|
-
import {
|
|
92
|
+
// ZorealSignIn.tsx — your own button, the way a production sign-in page uses it.
|
|
93
|
+
import { useState } from 'react';
|
|
94
|
+
import { useZorealLogin, ZorealBusyRing, ZorealMark } from '@zoreal/oauth2-react';
|
|
95
|
+
|
|
96
|
+
function ZorealSignIn({ onSignedIn }: { onSignedIn: () => void }) {
|
|
97
|
+
// Busy from the tap until the flow ends. On a computer that is while the
|
|
98
|
+
// pairing modal is open; on a phone it is the moment between the tap and
|
|
99
|
+
// the hand-over to the ZOREAL ID app. Cleared by every outcome below.
|
|
100
|
+
const [busy, setBusy] = useState(false);
|
|
94
101
|
|
|
95
|
-
function ZorealSignIn() {
|
|
96
102
|
// `email` (and profile.name, etc.) are returned from /userinfo on your backend.
|
|
97
103
|
const login = useZorealLogin({
|
|
98
104
|
flow: 'auth-code',
|
|
99
105
|
scope: 'openid email profile.name',
|
|
100
106
|
onSuccess: async ({ code, code_verifier, nonce }) => {
|
|
107
|
+
setBusy(false);
|
|
101
108
|
// Send ALL THREE to your backend over TLS. It calls POST /token with the
|
|
102
109
|
// code and verifier plus its client authentication, verifies the ID
|
|
103
110
|
// token's nonce is this one, then reads the email and name from
|
|
104
111
|
// /userinfo. That is where personal data is delivered.
|
|
105
|
-
await fetch('/api/auth/zoreal', {
|
|
112
|
+
const res = await fetch('/api/auth/zoreal', {
|
|
106
113
|
method: 'POST',
|
|
107
114
|
headers: { 'Content-Type': 'application/json' },
|
|
108
115
|
body: JSON.stringify({ code, code_verifier, nonce }),
|
|
109
116
|
});
|
|
117
|
+
if (res.ok) onSignedIn();
|
|
118
|
+
},
|
|
119
|
+
onError: (e) => {
|
|
120
|
+
setBusy(false);
|
|
121
|
+
console.error(e.description ?? e.error);
|
|
110
122
|
},
|
|
111
|
-
onError: (e) => console.error(e.description ?? e.error),
|
|
112
123
|
onNonOAuthError: (e) => {
|
|
113
|
-
|
|
114
|
-
|
|
124
|
+
setBusy(false);
|
|
125
|
+
// The holder declining or ignoring the request, or closing the dialog,
|
|
126
|
+
// is not an error to surface: the button is simply ready again.
|
|
127
|
+
if (e.type === 'request_denied' || e.type === 'request_expired' || e.type === 'popup_closed') return;
|
|
115
128
|
console.error(e.description ?? e.type);
|
|
116
129
|
},
|
|
117
130
|
});
|
|
118
131
|
|
|
119
|
-
return
|
|
132
|
+
return (
|
|
133
|
+
// The wrapper runs the pairing modal's light around the button while busy.
|
|
134
|
+
// `block` because this button fills its row; `radius` is the button's own.
|
|
135
|
+
<ZorealBusyRing busy={busy} radius={12} block>
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
disabled={busy}
|
|
139
|
+
aria-busy={busy}
|
|
140
|
+
onClick={() => {
|
|
141
|
+
if (busy) return;
|
|
142
|
+
setBusy(true);
|
|
143
|
+
login(); // from the click handler itself: on a phone this tap is the navigation
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
<ZorealMark size={22} brand />
|
|
147
|
+
Continue with ZOREAL
|
|
148
|
+
</button>
|
|
149
|
+
</ZorealBusyRing>
|
|
150
|
+
);
|
|
120
151
|
}
|
|
121
152
|
```
|
|
122
153
|
|
|
123
|
-
That is the whole integration. When `login()` runs, the provider
|
|
124
|
-
pairing modal on screen
|
|
125
|
-
|
|
126
|
-
|
|
154
|
+
That is the whole integration. When `login()` runs on a computer, the provider
|
|
155
|
+
puts the pairing modal on screen. On a phone the tap itself navigates to the
|
|
156
|
+
provider, which opens the ZOREAL ID app; once the person has approved, the app
|
|
157
|
+
brings them back to this page, and the same hook finishes the sign-in and calls
|
|
158
|
+
your `onSuccess` there. So mount this component on the page the sign-in starts
|
|
159
|
+
from, and expect `onSuccess` on a fresh page load. See
|
|
160
|
+
[The pairing modal](#the-pairing-modal) for what it does and how to theme,
|
|
161
|
+
translate, time out or replace it.
|
|
127
162
|
|
|
128
163
|
## Quick start: the button (no backend, pseudonymous)
|
|
129
164
|
|
|
@@ -251,7 +286,7 @@ What the modal does:
|
|
|
251
286
|
|
|
252
287
|
| | |
|
|
253
288
|
| --- | --- |
|
|
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'`. |
|
|
289
|
+
| **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
290
|
| **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
291
|
| **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
292
|
| **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 +586,7 @@ These reach your callbacks before any backend is involved — handle them here:
|
|
|
551
586
|
| `/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
587
|
| pairing | `onNonOAuthError` | `request_denied` | The holder declined in their ZOREAL ID app — **not an error to alarm on**; offer to try again |
|
|
553
588
|
| pairing | `onNonOAuthError` | `request_expired` | The pairing window elapsed, or a required liveness the device could not meet — offer to try again |
|
|
589
|
+
| 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
590
|
|
|
555
591
|
### The type unions
|
|
556
592
|
|
|
@@ -576,28 +612,28 @@ error path.
|
|
|
576
612
|
|
|
577
613
|
## A complete example
|
|
578
614
|
|
|
579
|
-
A full sign-in component, end to end
|
|
580
|
-
takes
|
|
581
|
-
|
|
582
|
-
|
|
615
|
+
A full sign-in component, end to end, the shape a production auth-code
|
|
616
|
+
integration takes: your own button, busy from the tap until the flow ends,
|
|
617
|
+
the SDK's pairing modal on a computer and the app hand-over on a phone,
|
|
618
|
+
`{ code, code_verifier, nonce }` to your backend on success, the human outcomes
|
|
619
|
+
treated as the non-events they are, and the return from the app on a phone
|
|
620
|
+
handled by the same hook.
|
|
583
621
|
|
|
584
622
|
```tsx
|
|
585
623
|
import { useState } from 'react';
|
|
586
|
-
import { ZorealOAuthProvider, useZorealLogin } from '@zoreal/oauth2-react';
|
|
587
|
-
import type { PairingState } from '@zoreal/oauth2-react';
|
|
624
|
+
import { ZorealOAuthProvider, useZorealLogin, ZorealBusyRing, ZorealMark } from '@zoreal/oauth2-react';
|
|
588
625
|
|
|
589
626
|
function ZorealSignIn() {
|
|
590
|
-
const [
|
|
627
|
+
const [busy, setBusy] = useState(false);
|
|
591
628
|
const [note, setNote] = useState<string | null>(null);
|
|
592
629
|
|
|
593
630
|
const login = useZorealLogin({
|
|
594
631
|
flow: 'auth-code',
|
|
595
632
|
scope: 'openid email profile.name',
|
|
596
633
|
// acr_values: 'zoreal.live', // request a fresh liveness for a step-up / high-value login
|
|
597
|
-
onPairingStateChange: setPairing,
|
|
598
634
|
|
|
599
635
|
onSuccess: async ({ code, code_verifier, nonce }) => {
|
|
600
|
-
|
|
636
|
+
setBusy(false);
|
|
601
637
|
// Post ALL THREE to YOUR backend over TLS. Your backend does the /token
|
|
602
638
|
// exchange with its client authentication, verifies the ID token
|
|
603
639
|
// (ES256 against the JWKS, iss/aud/exp, and this nonce), checks the acr
|
|
@@ -619,37 +655,38 @@ function ZorealSignIn() {
|
|
|
619
655
|
|
|
620
656
|
// An OAuth error from the provider (e.g. a scope not on your allow list).
|
|
621
657
|
onError: (e) => {
|
|
622
|
-
|
|
658
|
+
setBusy(false);
|
|
623
659
|
setNote(e.description ?? e.error); // the provider's words, verbatim
|
|
624
660
|
},
|
|
625
661
|
|
|
626
|
-
// The human outcomes: declined, expired,
|
|
627
|
-
//
|
|
662
|
+
// The human outcomes: declined, expired, the dialog closed. Not faults:
|
|
663
|
+
// the button is ready again. Do not alarm on these.
|
|
628
664
|
onNonOAuthError: (e) => {
|
|
629
|
-
|
|
665
|
+
setBusy(false);
|
|
630
666
|
if (e.type === 'request_denied') setNote('Login was declined. Try again when ready.');
|
|
631
667
|
else if (e.type === 'request_expired') setNote('That took too long. Try again.');
|
|
668
|
+
else if (e.type === 'popup_closed') setNote(null);
|
|
632
669
|
else setNote('Something went wrong. Try again.');
|
|
633
670
|
},
|
|
634
671
|
});
|
|
635
672
|
|
|
636
673
|
return (
|
|
637
674
|
<div>
|
|
638
|
-
<
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
</
|
|
652
|
-
|
|
675
|
+
<ZorealBusyRing busy={busy} radius={12} block>
|
|
676
|
+
<button
|
|
677
|
+
type="button"
|
|
678
|
+
disabled={busy}
|
|
679
|
+
aria-busy={busy}
|
|
680
|
+
onClick={() => {
|
|
681
|
+
if (busy) return;
|
|
682
|
+
setBusy(true);
|
|
683
|
+
login();
|
|
684
|
+
}}
|
|
685
|
+
>
|
|
686
|
+
<ZorealMark size={22} brand />
|
|
687
|
+
Continue with ZOREAL
|
|
688
|
+
</button>
|
|
689
|
+
</ZorealBusyRing>
|
|
653
690
|
|
|
654
691
|
{note && <p role="status">{note}</p>}
|
|
655
692
|
</div>
|
|
@@ -658,13 +695,20 @@ function ZorealSignIn() {
|
|
|
658
695
|
|
|
659
696
|
export default function App() {
|
|
660
697
|
return (
|
|
661
|
-
<ZorealOAuthProvider clientId="ast_your_asset_id">
|
|
698
|
+
<ZorealOAuthProvider clientId="ast_your_asset_id" locale="en" theme="auto">
|
|
662
699
|
<ZorealSignIn />
|
|
663
700
|
</ZorealOAuthProvider>
|
|
664
701
|
);
|
|
665
702
|
}
|
|
666
703
|
```
|
|
667
704
|
|
|
705
|
+
On a computer the provider renders the pairing modal for this button; nothing
|
|
706
|
+
here draws a QR. To draw your own instead, see
|
|
707
|
+
[Rendering it yourself](#rendering-it-yourself). On a phone there is no dialog:
|
|
708
|
+
the tap navigates to the provider, the ZOREAL ID app opens, and after the
|
|
709
|
+
approval the app reopens this page, where `useZorealLogin` finishes the sign-in
|
|
710
|
+
and `onSuccess` runs on that fresh page load.
|
|
711
|
+
|
|
668
712
|
**The backend must verify.** This component only starts the flow and forwards a
|
|
669
713
|
code; on its own it proves nothing. The security is your backend exchanging the
|
|
670
714
|
code with its client authentication and verifying the signed ID token — use a
|
package/dist/index.cjs
CHANGED
|
@@ -25,6 +25,7 @@ __export(index_exports, {
|
|
|
25
25
|
PairingModal: () => PairingModal,
|
|
26
26
|
ZorealBusyRing: () => ZorealBusyRing,
|
|
27
27
|
ZorealLogin: () => ZorealLogin,
|
|
28
|
+
ZorealMark: () => ZorealMark,
|
|
28
29
|
ZorealOAuthProvider: () => ZorealOAuthProvider,
|
|
29
30
|
hasGrantedAllScopesZoreal: () => hasGrantedAllScopesZoreal,
|
|
30
31
|
hasGrantedAnyScopeZoreal: () => hasGrantedAnyScopeZoreal,
|
|
@@ -41,7 +42,7 @@ var import_react2 = require("react");
|
|
|
41
42
|
|
|
42
43
|
// src/wire.ts
|
|
43
44
|
var WIRE_VERSION = 1;
|
|
44
|
-
var SDK_VERSION = "0.2.
|
|
45
|
+
var SDK_VERSION = "0.2.20";
|
|
45
46
|
var DEFAULT_ISSUER = "https://id.zoreal.com";
|
|
46
47
|
var POLL_INTERVAL_MS = 2e3;
|
|
47
48
|
var POLL_INTERVAL_ENROLLING_MS = 5e3;
|
|
@@ -1636,6 +1637,76 @@ var import_react5 = require("react");
|
|
|
1636
1637
|
// src/useZorealLogin.ts
|
|
1637
1638
|
var import_react3 = require("react");
|
|
1638
1639
|
|
|
1640
|
+
// src/return.ts
|
|
1641
|
+
var PREFIX2 = "zoreal:oauth2:return:";
|
|
1642
|
+
var DONE = "zoreal:oauth2:done:";
|
|
1643
|
+
var MAX_AGE_MS = 10 * 60 * 1e3;
|
|
1644
|
+
function storage() {
|
|
1645
|
+
try {
|
|
1646
|
+
return typeof localStorage === "undefined" ? null : localStorage;
|
|
1647
|
+
} catch {
|
|
1648
|
+
return null;
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
function saveReturnFlow(flow) {
|
|
1652
|
+
try {
|
|
1653
|
+
storage()?.setItem(PREFIX2 + flow.requestId, JSON.stringify(flow));
|
|
1654
|
+
} catch {
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
function peekReturnFlow(requestId) {
|
|
1658
|
+
const store = storage();
|
|
1659
|
+
if (!store) return null;
|
|
1660
|
+
const raw = store.getItem(PREFIX2 + requestId);
|
|
1661
|
+
if (!raw) return null;
|
|
1662
|
+
try {
|
|
1663
|
+
const flow = JSON.parse(raw);
|
|
1664
|
+
if (flow.v !== 1 || flow.requestId !== requestId) return null;
|
|
1665
|
+
if (Date.now() - flow.createdAt > MAX_AGE_MS) return null;
|
|
1666
|
+
return flow;
|
|
1667
|
+
} catch {
|
|
1668
|
+
return null;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
function forgetReturnFlow(requestId) {
|
|
1672
|
+
try {
|
|
1673
|
+
storage()?.removeItem(PREFIX2 + requestId);
|
|
1674
|
+
} catch {
|
|
1675
|
+
}
|
|
1676
|
+
if (pending === requestId) pending = null;
|
|
1677
|
+
}
|
|
1678
|
+
function markReturnDone(requestId) {
|
|
1679
|
+
try {
|
|
1680
|
+
const store = storage();
|
|
1681
|
+
store?.setItem(DONE + requestId, String(Date.now()));
|
|
1682
|
+
store?.removeItem(PREFIX2 + requestId);
|
|
1683
|
+
} catch {
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
function isReturnDone(requestId) {
|
|
1687
|
+
return storage()?.getItem(DONE + requestId) !== null && storage()?.getItem(DONE + requestId) !== void 0;
|
|
1688
|
+
}
|
|
1689
|
+
function returnToUrl() {
|
|
1690
|
+
if (typeof window === "undefined") return void 0;
|
|
1691
|
+
const { href } = window.location;
|
|
1692
|
+
const hash = href.indexOf("#");
|
|
1693
|
+
return hash === -1 ? href : href.slice(0, hash);
|
|
1694
|
+
}
|
|
1695
|
+
var RETURN_MARK = /(?:^|[#&])zoreal_return=([A-Za-z0-9]{32})(?:&|$)/;
|
|
1696
|
+
var pending = null;
|
|
1697
|
+
function pendingReturnId() {
|
|
1698
|
+
if (pending) return pending;
|
|
1699
|
+
if (typeof window === "undefined") return null;
|
|
1700
|
+
const match = RETURN_MARK.exec(window.location.hash);
|
|
1701
|
+
if (!match) return null;
|
|
1702
|
+
pending = match[1];
|
|
1703
|
+
try {
|
|
1704
|
+
window.history.replaceState(window.history.state, "", returnToUrl());
|
|
1705
|
+
} catch {
|
|
1706
|
+
}
|
|
1707
|
+
return pending;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1639
1710
|
// src/jwt.ts
|
|
1640
1711
|
function unsafeClaims(idToken) {
|
|
1641
1712
|
try {
|
|
@@ -2015,6 +2086,7 @@ function generateRequestId() {
|
|
|
2015
2086
|
}
|
|
2016
2087
|
|
|
2017
2088
|
// src/useZorealLogin.ts
|
|
2089
|
+
var resumedReturns = /* @__PURE__ */ new Set();
|
|
2018
2090
|
function useZorealFlow(options) {
|
|
2019
2091
|
const { clientId, issuer, locale } = useZorealOAuth();
|
|
2020
2092
|
const [pairing, setPairing] = (0, import_react3.useState)(null);
|
|
@@ -2024,6 +2096,7 @@ function useZorealFlow(options) {
|
|
|
2024
2096
|
const abortRef = (0, import_react3.useRef)(null);
|
|
2025
2097
|
const optionsRef = (0, import_react3.useRef)(options);
|
|
2026
2098
|
optionsRef.current = options;
|
|
2099
|
+
const closedByPerson = (0, import_react3.useRef)(false);
|
|
2027
2100
|
(0, import_react3.useEffect)(
|
|
2028
2101
|
() => () => {
|
|
2029
2102
|
abortRef.current?.abort();
|
|
@@ -2031,6 +2104,61 @@ function useZorealFlow(options) {
|
|
|
2031
2104
|
},
|
|
2032
2105
|
[]
|
|
2033
2106
|
);
|
|
2107
|
+
(0, import_react3.useEffect)(() => {
|
|
2108
|
+
const id = pendingReturnId();
|
|
2109
|
+
if (!id || resumedReturns.has(id)) return;
|
|
2110
|
+
const saved = peekReturnFlow(id);
|
|
2111
|
+
if (!saved || saved.clientId !== clientId) return;
|
|
2112
|
+
forgetReturnFlow(id);
|
|
2113
|
+
resumedReturns.add(id);
|
|
2114
|
+
const controller = new AbortController();
|
|
2115
|
+
abortRef.current = controller;
|
|
2116
|
+
void (async () => {
|
|
2117
|
+
const opts = optionsRef.current;
|
|
2118
|
+
try {
|
|
2119
|
+
const code = await pollUntilApproved(issuer, id, void 0, controller.signal, {
|
|
2120
|
+
tolerateUnknownUntil: Date.now() + 5e3
|
|
2121
|
+
});
|
|
2122
|
+
if (saved.flow === "auth-code") {
|
|
2123
|
+
opts.onCode?.({
|
|
2124
|
+
code,
|
|
2125
|
+
scope: saved.scope,
|
|
2126
|
+
app_state: saved.appState,
|
|
2127
|
+
code_verifier: saved.verifier,
|
|
2128
|
+
nonce: saved.nonce
|
|
2129
|
+
});
|
|
2130
|
+
} else {
|
|
2131
|
+
const tokens = await exchangeCode(issuer, {
|
|
2132
|
+
code,
|
|
2133
|
+
code_verifier: saved.verifier,
|
|
2134
|
+
client_id: clientId
|
|
2135
|
+
});
|
|
2136
|
+
const claims = unsafeClaims(tokens.id_token);
|
|
2137
|
+
opts.onCredential?.({
|
|
2138
|
+
credential: tokens.id_token,
|
|
2139
|
+
clientId,
|
|
2140
|
+
select_by: "app_link",
|
|
2141
|
+
acr: claims.acr ?? "zoreal.device"
|
|
2142
|
+
});
|
|
2143
|
+
}
|
|
2144
|
+
markReturnDone(id);
|
|
2145
|
+
} catch (e) {
|
|
2146
|
+
if (e instanceof DOMException && e.name === "AbortError") return;
|
|
2147
|
+
if (e instanceof FlowAbandonedError) {
|
|
2148
|
+
opts.onNonOAuthError?.(e.reason);
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
2151
|
+
if (e instanceof OAuthFlowError) {
|
|
2152
|
+
opts.onError?.({ error: e.error, description: e.description });
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
opts.onNonOAuthError?.({
|
|
2156
|
+
type: "unknown",
|
|
2157
|
+
description: e instanceof Error ? e.message : String(e)
|
|
2158
|
+
});
|
|
2159
|
+
}
|
|
2160
|
+
})();
|
|
2161
|
+
}, [clientId, issuer]);
|
|
2034
2162
|
const login = (0, import_react3.useCallback)(() => {
|
|
2035
2163
|
const opts = optionsRef.current;
|
|
2036
2164
|
const run = async () => {
|
|
@@ -2047,8 +2175,23 @@ function useZorealFlow(options) {
|
|
|
2047
2175
|
try {
|
|
2048
2176
|
let code;
|
|
2049
2177
|
let selectBy = "device";
|
|
2178
|
+
let returnId = null;
|
|
2050
2179
|
if (useAppLink) {
|
|
2051
2180
|
const requestId = generateRequestId();
|
|
2181
|
+
saveReturnFlow({
|
|
2182
|
+
v: 1,
|
|
2183
|
+
issuer,
|
|
2184
|
+
clientId,
|
|
2185
|
+
flow,
|
|
2186
|
+
verifier,
|
|
2187
|
+
nonce,
|
|
2188
|
+
state,
|
|
2189
|
+
scope: opts.scope ?? "openid",
|
|
2190
|
+
appState: opts.app_state,
|
|
2191
|
+
requestId,
|
|
2192
|
+
createdAt: Date.now()
|
|
2193
|
+
});
|
|
2194
|
+
returnId = requestId;
|
|
2052
2195
|
const startUrl = sameDeviceStartUrl(issuer, {
|
|
2053
2196
|
client_id: clientId,
|
|
2054
2197
|
scope: opts.scope ?? "openid",
|
|
@@ -2061,10 +2204,12 @@ function useZorealFlow(options) {
|
|
|
2061
2204
|
prompt: opts.prompt,
|
|
2062
2205
|
locale,
|
|
2063
2206
|
request_id: requestId,
|
|
2064
|
-
origin: window.location.origin
|
|
2207
|
+
origin: window.location.origin,
|
|
2208
|
+
return_to: returnToUrl()
|
|
2065
2209
|
});
|
|
2066
2210
|
selectBy = "app_link";
|
|
2067
2211
|
const cancel = () => {
|
|
2212
|
+
closedByPerson.current = true;
|
|
2068
2213
|
controller.abort();
|
|
2069
2214
|
setPairing(null);
|
|
2070
2215
|
};
|
|
@@ -2091,6 +2236,9 @@ function useZorealFlow(options) {
|
|
|
2091
2236
|
controller.signal,
|
|
2092
2237
|
{ tolerateUnknownUntil: Date.now() + 15e3 }
|
|
2093
2238
|
);
|
|
2239
|
+
if (isReturnDone(requestId)) {
|
|
2240
|
+
throw new DOMException("aborted", "AbortError");
|
|
2241
|
+
}
|
|
2094
2242
|
} else {
|
|
2095
2243
|
const started = await startPairing(issuer, {
|
|
2096
2244
|
client_id: clientId,
|
|
@@ -2112,6 +2260,7 @@ function useZorealFlow(options) {
|
|
|
2112
2260
|
selectBy = "qr";
|
|
2113
2261
|
const qrRefreshSeconds = qrRefreshSecondsOf(started);
|
|
2114
2262
|
const cancel = () => {
|
|
2263
|
+
closedByPerson.current = true;
|
|
2115
2264
|
controller.abort();
|
|
2116
2265
|
setPairing(null);
|
|
2117
2266
|
publishRef.current?.(null);
|
|
@@ -2161,6 +2310,7 @@ function useZorealFlow(options) {
|
|
|
2161
2310
|
}
|
|
2162
2311
|
setPairing(null);
|
|
2163
2312
|
publishRef.current?.(null);
|
|
2313
|
+
if (returnId) markReturnDone(returnId);
|
|
2164
2314
|
if (flow === "auth-code") {
|
|
2165
2315
|
opts.onCode?.({
|
|
2166
2316
|
code,
|
|
@@ -2187,7 +2337,16 @@ function useZorealFlow(options) {
|
|
|
2187
2337
|
} catch (e) {
|
|
2188
2338
|
setPairing(null);
|
|
2189
2339
|
publishRef.current?.(null);
|
|
2190
|
-
if (e instanceof DOMException && e.name === "AbortError")
|
|
2340
|
+
if (e instanceof DOMException && e.name === "AbortError") {
|
|
2341
|
+
if (closedByPerson.current) {
|
|
2342
|
+
closedByPerson.current = false;
|
|
2343
|
+
opts.onNonOAuthError?.({
|
|
2344
|
+
type: "popup_closed",
|
|
2345
|
+
description: "the sign-in dialog was closed before the holder approved"
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2348
|
+
return;
|
|
2349
|
+
}
|
|
2191
2350
|
if (e instanceof FlowAbandonedError) {
|
|
2192
2351
|
opts.onNonOAuthError?.(e.reason);
|
|
2193
2352
|
return;
|
|
@@ -2432,6 +2591,7 @@ function hasGrantedAnyScopeZoreal(response, firstScope, ...restScopes) {
|
|
|
2432
2591
|
PairingModal,
|
|
2433
2592
|
ZorealBusyRing,
|
|
2434
2593
|
ZorealLogin,
|
|
2594
|
+
ZorealMark,
|
|
2435
2595
|
ZorealOAuthProvider,
|
|
2436
2596
|
hasGrantedAllScopesZoreal,
|
|
2437
2597
|
hasGrantedAnyScopeZoreal,
|