@zoreal/oauth2-js 0.1.9 → 0.1.11
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 +29 -10
- package/dist/index.cjs +224 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +97 -16
- package/dist/index.d.ts +97 -16
- package/dist/index.js +223 -19
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -130,9 +130,17 @@ on screen, so this package does it. `startLogin` mounts a dialog, keeps it in
|
|
|
130
130
|
step with the pairing, and takes it down when the flow settles. You render
|
|
131
131
|
nothing.
|
|
132
132
|
|
|
133
|
+
The code on screen changes every few seconds. Each image is one frame of the
|
|
134
|
+
pairing, and the provider refuses a frame more than 30 seconds old, so a
|
|
135
|
+
screenshot of the code passed to someone else is already dead when it arrives:
|
|
136
|
+
signing in needs the screen as it is right now. The provider renders the
|
|
137
|
+
frames and this package re-fetches the image on the interval the provider
|
|
138
|
+
gives it, preloading the next one so the code never flickers to blank. There
|
|
139
|
+
is nothing to configure and nothing to draw.
|
|
140
|
+
|
|
133
141
|
| | |
|
|
134
142
|
| --- | --- |
|
|
135
|
-
| **Mobile** | No QR. The pairing link opens the ZOREAL ID app and the modal never appears. Force one or the other with `display: 'qr'` / `'link'`. |
|
|
143
|
+
| **Mobile** | No QR. The pairing link opens the ZOREAL ID app and the modal never appears. 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. |
|
|
136
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). |
|
|
137
145
|
| **Countdown** | Counts down to expiry, turning amber under 20s. Reads the clock each tick rather than decrementing, so a backgrounded tab comes back honest. |
|
|
138
146
|
| **Timeout** | Closes and cancels at zero. Defaults to 120s; override with `pairingTimeoutMs`. The provider's own expiry wins when it is shorter. |
|
|
@@ -184,6 +192,11 @@ startLogin({ clientId: 'ast_your_asset_id', pairingUI: 'none' });
|
|
|
184
192
|
your own. `mountPairingModal` is also exported if you want the real dialog but
|
|
185
193
|
driven on your own terms.
|
|
186
194
|
|
|
195
|
+
Render the `qrUrl` from the state you are given, every time, and never cache
|
|
196
|
+
the first one: it is a moving code, a fresh frame arrives every
|
|
197
|
+
`qrRefreshSeconds`, and a UI holding the first URL shows one the provider has
|
|
198
|
+
already refused.
|
|
199
|
+
|
|
187
200
|
## The handle
|
|
188
201
|
|
|
189
202
|
`startLogin` returns synchronously with everything a UI needs to drive the
|
|
@@ -194,8 +207,8 @@ flow:
|
|
|
194
207
|
| `promise` | resolves with the mode's result; rejects with `OAuthFlowError`, `FlowAbandonedError`, or an `AbortError` after `cancel()` |
|
|
195
208
|
| `cancel()` | abandons the flow: stops the poll, rejects the promise |
|
|
196
209
|
| `requestId` | the pairing request id, once the provider has created it |
|
|
197
|
-
| `pairUrl` | the pairing link, once created.
|
|
198
|
-
| `qrUrl` | the provider-served SVG of
|
|
210
|
+
| `pairUrl` | the pairing link, once created. On an app-link flow it carries the start token; navigate to it verbatim |
|
|
211
|
+
| `qrUrl` | the provider-served SVG of the current code. Put it in an `<img>`; do not draw your own. It changes while the pairing is pending, so read it from `onState` rather than here |
|
|
199
212
|
| `appLink` | true when the flow resolved to the app link (mobile) rather than a QR |
|
|
200
213
|
|
|
201
214
|
`requestId`, `pairUrl`, `qrUrl` and `appLink` are `undefined` until the
|
|
@@ -203,10 +216,10 @@ pairing request exists (one round-trip), and stay `undefined` when
|
|
|
203
216
|
`prompt: 'none'` resolves silently. The same four values also arrive on every
|
|
204
217
|
`onState` callback, which is the reliable place to render from.
|
|
205
218
|
|
|
206
|
-
`onState` receives a `PairingState` on every change:
|
|
219
|
+
`onState` receives a `PairingState` on every change, and on every QR frame:
|
|
207
220
|
`status` (`pending | claimed | approved | denied | expired | enrolling`),
|
|
208
|
-
`expiresIn`, `enrolmentDeadline`, `pairUrl`, `qrUrl`, `
|
|
209
|
-
`cancel`.
|
|
221
|
+
`expiresIn`, `enrolmentDeadline`, `pairUrl`, `qrUrl`, `qrRefreshSeconds`,
|
|
222
|
+
`appLink`, and `cancel`.
|
|
210
223
|
|
|
211
224
|
## What resolves, per mode
|
|
212
225
|
|
|
@@ -367,6 +380,7 @@ enforced where enforcement counts: on your backend, against the verified token.
|
|
|
367
380
|
| `isMobileUserAgent()` | whether this user agent gets the app link rather than a QR |
|
|
368
381
|
| `mountPairingModal(state, { onCancel, locale?, theme?, timeoutMs? })` | mounts the dialog yourself, for `pairingUI: 'none'` callers who still want the real one. Returns `{ update, close }`, or `null` outside a browser |
|
|
369
382
|
| `DEFAULT_PAIRING_TIMEOUT_MS` | `120000`, the modal's default cap |
|
|
383
|
+
| `DEFAULT_QR_REFRESH_SECONDS` | `3`, how often the QR frame is re-fetched when the provider does not say |
|
|
370
384
|
|
|
371
385
|
Errors: `OAuthFlowError` (the provider refused; `error` is the OAuth code,
|
|
372
386
|
`description` is the provider's reason verbatim) and `FlowAbandonedError` (a
|
|
@@ -383,8 +397,8 @@ is sent to the provider AND picks the modal's own language. See
|
|
|
383
397
|
All types are exported: `PairingState`, `ZorealCredentialResponse`,
|
|
384
398
|
`ZorealCodeResponse`, `StartLoginOptions`, `BrowserDirectLoginOptions`,
|
|
385
399
|
`AuthCodeLoginOptions`, `LoginHandle`, `ErrorCode`, `NonOAuthError`,
|
|
386
|
-
`SelectBy`, `AcrValue`, `PairingUI`, `
|
|
387
|
-
`PairingModalOptions`, and the wire shapes.
|
|
400
|
+
`SelectBy`, `AcrValue`, `PairingUI`, `PairDisplay`, `ZorealTheme`,
|
|
401
|
+
`PairingModalHandle`, `PairingModalOptions`, and the wire shapes.
|
|
388
402
|
|
|
389
403
|
## Error reference
|
|
390
404
|
|
|
@@ -469,7 +483,10 @@ export function mountZorealButton(root: HTMLElement) {
|
|
|
469
483
|
}
|
|
470
484
|
if (s.qrUrl) {
|
|
471
485
|
const img = document.createElement('img');
|
|
472
|
-
|
|
486
|
+
// Provider-served, and a new frame every few seconds: never draw your
|
|
487
|
+
// own QR of pairUrl, and never keep the first URL. A real UI keeps one
|
|
488
|
+
// <img> and assigns src, rather than rebuilding it as this sketch does.
|
|
489
|
+
img.src = s.qrUrl;
|
|
473
490
|
img.alt = 'Scan with the ZOREAL ID app';
|
|
474
491
|
panel.append(img);
|
|
475
492
|
}
|
|
@@ -609,7 +626,9 @@ And the template renders the state:
|
|
|
609
626
|
The same shape ports to Svelte (a store fed by `onState`) or Angular (a
|
|
610
627
|
service exposing an observable). The rules a wrapper must keep:
|
|
611
628
|
|
|
612
|
-
- Render `qrUrl` in an `<img>`; never draw your own QR of `pairUrl`.
|
|
629
|
+
- Render `qrUrl` in an `<img>`; never draw your own QR of `pairUrl`. Re-render
|
|
630
|
+
it on every state: the code moves, and the last frame you were given is the
|
|
631
|
+
only one the provider still accepts.
|
|
613
632
|
- Call `cancel()` on unmount or navigation. Do not add your own retry loop:
|
|
614
633
|
the poll cadence is fixed because over-polling cancels the request
|
|
615
634
|
server-side.
|
package/dist/index.cjs
CHANGED
|
@@ -22,6 +22,7 @@ var index_exports = {};
|
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
DEFAULT_ISSUER: () => DEFAULT_ISSUER,
|
|
24
24
|
DEFAULT_PAIRING_TIMEOUT_MS: () => DEFAULT_PAIRING_TIMEOUT_MS,
|
|
25
|
+
DEFAULT_QR_REFRESH_SECONDS: () => DEFAULT_QR_REFRESH_SECONDS,
|
|
25
26
|
FlowAbandonedError: () => FlowAbandonedError,
|
|
26
27
|
OAuthFlowError: () => OAuthFlowError,
|
|
27
28
|
POLL_INTERVAL_ENROLLING_MS: () => POLL_INTERVAL_ENROLLING_MS,
|
|
@@ -58,11 +59,12 @@ function unsafeClaims(idToken) {
|
|
|
58
59
|
|
|
59
60
|
// src/wire.ts
|
|
60
61
|
var WIRE_VERSION = 1;
|
|
61
|
-
var SDK_VERSION = "0.1.
|
|
62
|
+
var SDK_VERSION = "0.1.11";
|
|
62
63
|
var SDK_NAME = "@zoreal/oauth2-js";
|
|
63
64
|
var DEFAULT_ISSUER = "https://id.zoreal.com";
|
|
64
65
|
var POLL_INTERVAL_MS = 2e3;
|
|
65
66
|
var POLL_INTERVAL_ENROLLING_MS = 5e3;
|
|
67
|
+
var DEFAULT_QR_REFRESH_SECONDS = 3;
|
|
66
68
|
|
|
67
69
|
// src/pairing.ts
|
|
68
70
|
var OAuthFlowError = class extends Error {
|
|
@@ -111,11 +113,15 @@ var sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
|
111
113
|
reject(new DOMException("aborted", "AbortError"));
|
|
112
114
|
return;
|
|
113
115
|
}
|
|
114
|
-
const
|
|
115
|
-
signal?.addEventListener("abort", () => {
|
|
116
|
+
const onAbort = () => {
|
|
116
117
|
clearTimeout(t);
|
|
117
118
|
reject(new DOMException("aborted", "AbortError"));
|
|
118
|
-
}
|
|
119
|
+
};
|
|
120
|
+
const t = setTimeout(() => {
|
|
121
|
+
signal?.removeEventListener("abort", onAbort);
|
|
122
|
+
resolve();
|
|
123
|
+
}, ms);
|
|
124
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
119
125
|
});
|
|
120
126
|
async function pollUntilApproved(issuer, requestId, onState, signal) {
|
|
121
127
|
for (; ; ) {
|
|
@@ -1006,20 +1012,110 @@ var CSS = `
|
|
|
1006
1012
|
color: var(--zrl-ink-soft);
|
|
1007
1013
|
}
|
|
1008
1014
|
|
|
1015
|
+
/* The light around the well: a thin ring in the accent, one bright arc
|
|
1016
|
+
travelling round it, and a soft glow of the same colour bleeding a few
|
|
1017
|
+
pixels outward. It says the pairing is live without a word.
|
|
1018
|
+
|
|
1019
|
+
It sits BELOW the well, which is why the well is wrapped instead of given a
|
|
1020
|
+
pseudo-element. Anything drawn inside the well paints over the white quiet
|
|
1021
|
+
zone the camera needs, and a negative z-index from inside the well lands
|
|
1022
|
+
wherever the card's stacking context happens to be that frame (the card's
|
|
1023
|
+
entrance animation makes and unmakes one). The wrapper is the well's old
|
|
1024
|
+
box exactly, same size, same margin, so nothing moves; the well keeps its
|
|
1025
|
+
own stacking order on top, and the ring cannot touch the QR. */
|
|
1026
|
+
.${PREFIX}-qr-halo {
|
|
1027
|
+
position: relative;
|
|
1028
|
+
isolation: isolate;
|
|
1029
|
+
width: 204px;
|
|
1030
|
+
height: 204px;
|
|
1031
|
+
margin: 20px auto 0;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1009
1034
|
.${PREFIX}-qr-well {
|
|
1010
1035
|
position: relative;
|
|
1036
|
+
z-index: 1;
|
|
1011
1037
|
display: grid;
|
|
1012
1038
|
place-items: center;
|
|
1013
1039
|
box-sizing: border-box;
|
|
1014
1040
|
width: 204px;
|
|
1015
1041
|
height: 204px;
|
|
1016
|
-
margin:
|
|
1042
|
+
margin: 0;
|
|
1017
1043
|
padding: 12px;
|
|
1018
1044
|
border: 1px solid var(--zrl-line);
|
|
1019
1045
|
border-radius: 16px;
|
|
1020
1046
|
background: var(--zrl-qr-bg);
|
|
1021
1047
|
}
|
|
1022
1048
|
|
|
1049
|
+
/* The glow is the ring's own alpha, blurred and drawn beneath it, so it
|
|
1050
|
+
follows the arc round and fades with it. It has to be a parent of the
|
|
1051
|
+
masked band: a filter is applied before a mask, so a shadow cast from the
|
|
1052
|
+
band itself would be cut off at the band's edge. Two shadows because one
|
|
1053
|
+
2px source blurred over 10px is too faint to read as light; the second
|
|
1054
|
+
blurs the first. */
|
|
1055
|
+
.${PREFIX}-qr-glow {
|
|
1056
|
+
position: absolute;
|
|
1057
|
+
inset: -2px;
|
|
1058
|
+
pointer-events: none;
|
|
1059
|
+
filter: drop-shadow(0 0 3px var(--zrl-accent)) drop-shadow(0 0 10px var(--zrl-accent));
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/* The band: a 2px ring just outside the well, cut with a mask that keeps the
|
|
1063
|
+
padding and drops the content box. The gradient is on a pseudo-element
|
|
1064
|
+
rather than on the band, because in the fallback it has to rotate as a
|
|
1065
|
+
whole while the band stays put. The prefixed mask is for Chrome before 120;
|
|
1066
|
+
the unprefixed one, declared after it, wins everywhere else. */
|
|
1067
|
+
.${PREFIX}-qr-arc {
|
|
1068
|
+
position: absolute;
|
|
1069
|
+
inset: 0;
|
|
1070
|
+
padding: 2px;
|
|
1071
|
+
border-radius: 18px;
|
|
1072
|
+
overflow: hidden;
|
|
1073
|
+
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
|
1074
|
+
-webkit-mask-composite: xor;
|
|
1075
|
+
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
|
1076
|
+
mask-composite: exclude;
|
|
1077
|
+
}
|
|
1078
|
+
.${PREFIX}-qr-arc::before,
|
|
1079
|
+
.${PREFIX}-qr-arc::after {
|
|
1080
|
+
content: '';
|
|
1081
|
+
position: absolute;
|
|
1082
|
+
transition: opacity 400ms cubic-bezier(0.23, 1, 0.32, 1);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/* The light. Mostly transparent, a tail that builds over a quarter turn, a
|
|
1086
|
+
short full-strength head and a drop-off within a few degrees, so it reads
|
|
1087
|
+
as travelling rather than blinking. One revolution every 3s, linear:
|
|
1088
|
+
continuous motion has no ease. Oversized so its corners still cover the
|
|
1089
|
+
band mid-rotation in the transform fallback; the property path shrinks it
|
|
1090
|
+
back to the band. */
|
|
1091
|
+
.${PREFIX}-qr-arc::before {
|
|
1092
|
+
inset: -50%;
|
|
1093
|
+
background: conic-gradient(
|
|
1094
|
+
from var(--zrl-angle, 0deg),
|
|
1095
|
+
transparent 0deg 245deg,
|
|
1096
|
+
var(--zrl-accent) 332deg 346deg,
|
|
1097
|
+
transparent 356deg 360deg
|
|
1098
|
+
);
|
|
1099
|
+
animation: ${PREFIX}-orbit 3s linear infinite;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/* The rest state: the same ring, evenly lit. Faint under the moving light so
|
|
1103
|
+
the ring reads as a ring; stronger once the light has stopped. */
|
|
1104
|
+
.${PREFIX}-qr-arc::after {
|
|
1105
|
+
inset: 0;
|
|
1106
|
+
background: var(--zrl-accent);
|
|
1107
|
+
opacity: 0.16;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/* Spent: the light stops where it is and fades, and the ring settles to a
|
|
1111
|
+
dim, even glow. Paused rather than removed, so it does not jump back to
|
|
1112
|
+
its start on the way out. */
|
|
1113
|
+
.${PREFIX}-qr-halo[data-spent="true"] .${PREFIX}-qr-arc::before {
|
|
1114
|
+
animation-play-state: paused;
|
|
1115
|
+
opacity: 0;
|
|
1116
|
+
}
|
|
1117
|
+
.${PREFIX}-qr-halo[data-spent="true"] .${PREFIX}-qr-arc::after { opacity: 0.34; }
|
|
1118
|
+
|
|
1023
1119
|
.${PREFIX}-qr {
|
|
1024
1120
|
display: block;
|
|
1025
1121
|
width: 100%;
|
|
@@ -1162,6 +1258,33 @@ var CSS = `
|
|
|
1162
1258
|
70%, 100% { transform: scale(2.6); opacity: 0 }
|
|
1163
1259
|
}
|
|
1164
1260
|
|
|
1261
|
+
/* Two ways to turn the light, one look.
|
|
1262
|
+
Where the browser can register a typed custom property, the gradient's own
|
|
1263
|
+
angle animates and the element never moves. Where it cannot (Firefox before
|
|
1264
|
+
128), an untyped custom property cannot interpolate and the light would
|
|
1265
|
+
snap once a cycle, so the oversized pseudo-element rotates instead and the
|
|
1266
|
+
band's mask keeps the ring in place. At rest the two are the same pixels:
|
|
1267
|
+
same gradient, same centre, angle zero.
|
|
1268
|
+
There is no direct query for @property support. transition-behavior
|
|
1269
|
+
shipped after it in every engine (Chrome 117 vs 85, Firefox 129 vs 128,
|
|
1270
|
+
Safari 17.4 vs 16.4), so it stands in: a yes is never wrong, and a no only
|
|
1271
|
+
sends a capable browser down the fallback, which looks the same.
|
|
1272
|
+
inherits: false so the angle stays on the element that animates it and
|
|
1273
|
+
never reaches the host page or the well. */
|
|
1274
|
+
@property --zrl-angle {
|
|
1275
|
+
syntax: '<angle>';
|
|
1276
|
+
inherits: false;
|
|
1277
|
+
initial-value: 0deg;
|
|
1278
|
+
}
|
|
1279
|
+
@keyframes ${PREFIX}-orbit { to { transform: rotate(360deg) } }
|
|
1280
|
+
@keyframes ${PREFIX}-orbit-angle { to { --zrl-angle: 360deg } }
|
|
1281
|
+
@supports (transition-behavior: allow-discrete) {
|
|
1282
|
+
.${PREFIX}-qr-arc::before {
|
|
1283
|
+
inset: 0;
|
|
1284
|
+
animation-name: ${PREFIX}-orbit-angle;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1165
1288
|
@media (prefers-reduced-motion: reduce) {
|
|
1166
1289
|
.${PREFIX}-scrim,
|
|
1167
1290
|
.${PREFIX}-card,
|
|
@@ -1171,6 +1294,10 @@ var CSS = `
|
|
|
1171
1294
|
.${PREFIX}-cancel,
|
|
1172
1295
|
.${PREFIX}-close,
|
|
1173
1296
|
.${PREFIX}-timer { transition: none }
|
|
1297
|
+
/* No travelling light. The ring keeps a still, soft glow instead, a step
|
|
1298
|
+
brighter than the spent state so pending and spent still read apart. */
|
|
1299
|
+
.${PREFIX}-qr-arc::before { animation: none; opacity: 0 }
|
|
1300
|
+
.${PREFIX}-qr-arc::after { opacity: 0.5 }
|
|
1174
1301
|
}
|
|
1175
1302
|
`;
|
|
1176
1303
|
function ensureStyles() {
|
|
@@ -1275,6 +1402,10 @@ function mountPairingModal(state, options) {
|
|
|
1275
1402
|
const title = el("h2", cx("title"));
|
|
1276
1403
|
title.id = titleId;
|
|
1277
1404
|
const bodyText = el("p", cx("body-text"));
|
|
1405
|
+
const halo = el("div", cx("qr-halo"));
|
|
1406
|
+
const glow = el("span", cx("qr-glow"));
|
|
1407
|
+
glow.setAttribute("aria-hidden", "true");
|
|
1408
|
+
glow.appendChild(el("span", cx("qr-arc")));
|
|
1278
1409
|
const well = el("div", cx("qr-well"));
|
|
1279
1410
|
const qr = el("img", cx("qr"));
|
|
1280
1411
|
qr.alt = t.qrAlt;
|
|
@@ -1286,13 +1417,14 @@ function mountPairingModal(state, options) {
|
|
|
1286
1417
|
badge.appendChild(strokeIcon(24, ["M8.5 2h7a2.5 2.5 0 0 1 2.5 2.5v15a2.5 2.5 0 0 1-2.5 2.5h-7A2.5 2.5 0 0 1 6 19.5v-15A2.5 2.5 0 0 1 8.5 2Z", "M11 18.5h2"], "1.8"));
|
|
1287
1418
|
overlay.appendChild(badge);
|
|
1288
1419
|
well.append(qr, overlay);
|
|
1420
|
+
halo.append(glow, well);
|
|
1289
1421
|
const status = el("div", cx("status"));
|
|
1290
1422
|
const dot = el("span", cx("dot"));
|
|
1291
1423
|
dot.append(el("i"), el("i"));
|
|
1292
1424
|
const statusLabel = el("span");
|
|
1293
1425
|
status.append(dot, statusLabel);
|
|
1294
1426
|
const timer = el("p", cx("timer"));
|
|
1295
|
-
body.append(mark, title, bodyText,
|
|
1427
|
+
body.append(mark, title, bodyText, halo, status, timer);
|
|
1296
1428
|
const help = el("div", cx("help"));
|
|
1297
1429
|
help.append(el("p", cx("help-title"), t.noIdTitle), el("p", cx("help-body"), t.noIdBody));
|
|
1298
1430
|
const footer = el("div", cx("footer"));
|
|
@@ -1305,14 +1437,38 @@ function mountPairingModal(state, options) {
|
|
|
1305
1437
|
scrim.appendChild(card);
|
|
1306
1438
|
const serverMs = typeof state.expiresIn === "number" ? state.expiresIn * 1e3 : Infinity;
|
|
1307
1439
|
const deadline = Date.now() + Math.min(timeoutMs, serverMs);
|
|
1440
|
+
let shown = state.qrUrl;
|
|
1441
|
+
let loading = null;
|
|
1442
|
+
const spent = () => qr.dataset.spent === "true";
|
|
1443
|
+
const showFrame = (url) => {
|
|
1444
|
+
if (url === shown || spent()) return;
|
|
1445
|
+
if (!shown) {
|
|
1446
|
+
qr.src = url;
|
|
1447
|
+
shown = url;
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
const next = new Image();
|
|
1451
|
+
loading = next;
|
|
1452
|
+
next.onload = () => {
|
|
1453
|
+
if (loading !== next || spent()) return;
|
|
1454
|
+
loading = null;
|
|
1455
|
+
qr.src = url;
|
|
1456
|
+
shown = url;
|
|
1457
|
+
};
|
|
1458
|
+
next.onerror = () => {
|
|
1459
|
+
if (loading === next) loading = null;
|
|
1460
|
+
};
|
|
1461
|
+
next.src = url;
|
|
1462
|
+
};
|
|
1308
1463
|
const paint = (s) => {
|
|
1309
1464
|
const settled = s.status === "claimed" || s.status === "enrolling";
|
|
1310
1465
|
title.textContent = settled ? t.titleApprove : t.title;
|
|
1311
1466
|
bodyText.textContent = s.status === "enrolling" ? t.bodyEnrolling : settled ? t.bodyApprove : t.bodyScan;
|
|
1312
1467
|
statusLabel.textContent = settled ? t.waitingApproval : t.waiting;
|
|
1313
1468
|
qr.dataset.spent = String(settled);
|
|
1469
|
+
halo.dataset.spent = String(settled);
|
|
1314
1470
|
overlay.style.display = settled ? "" : "none";
|
|
1315
|
-
if (s.qrUrl
|
|
1471
|
+
if (s.qrUrl) showFrame(s.qrUrl);
|
|
1316
1472
|
};
|
|
1317
1473
|
const tick = () => {
|
|
1318
1474
|
const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1e3));
|
|
@@ -1329,6 +1485,7 @@ function mountPairingModal(state, options) {
|
|
|
1329
1485
|
const close = () => {
|
|
1330
1486
|
if (closed) return;
|
|
1331
1487
|
closed = true;
|
|
1488
|
+
loading = null;
|
|
1332
1489
|
window.clearInterval(interval);
|
|
1333
1490
|
document.removeEventListener("keydown", onKey);
|
|
1334
1491
|
document.body.style.overflow = previousOverflow;
|
|
@@ -1378,10 +1535,15 @@ function startLogin(options) {
|
|
|
1378
1535
|
const flow = options.flow ?? "browser-direct";
|
|
1379
1536
|
const issuer = options.issuer ?? DEFAULT_ISSUER;
|
|
1380
1537
|
const controller = new AbortController();
|
|
1538
|
+
const useAppLink = options.display === "link" || options.display !== "qr" && isMobileUserAgent();
|
|
1381
1539
|
const surface = {};
|
|
1382
1540
|
const cancel = () => controller.abort();
|
|
1383
1541
|
let modal = null;
|
|
1384
|
-
|
|
1542
|
+
let stopRefresh = () => {
|
|
1543
|
+
};
|
|
1544
|
+
controller.signal.addEventListener("abort", () => stopRefresh());
|
|
1545
|
+
const teardown = () => {
|
|
1546
|
+
stopRefresh();
|
|
1385
1547
|
modal?.close();
|
|
1386
1548
|
modal = null;
|
|
1387
1549
|
};
|
|
@@ -1402,7 +1564,8 @@ function startLogin(options) {
|
|
|
1402
1564
|
acr_values: Array.isArray(options.acr_values) ? options.acr_values.join(" ") : options.acr_values,
|
|
1403
1565
|
max_age: options.max_age,
|
|
1404
1566
|
prompt: options.prompt,
|
|
1405
|
-
locale: options.locale
|
|
1567
|
+
locale: options.locale,
|
|
1568
|
+
display: useAppLink ? "link" : "qr"
|
|
1406
1569
|
},
|
|
1407
1570
|
controller.signal
|
|
1408
1571
|
);
|
|
@@ -1412,19 +1575,25 @@ function startLogin(options) {
|
|
|
1412
1575
|
code = started.code;
|
|
1413
1576
|
selectBy = "session";
|
|
1414
1577
|
} else {
|
|
1415
|
-
const useAppLink = options.display === "link" || options.display !== "qr" && isMobileUserAgent();
|
|
1416
1578
|
selectBy = useAppLink ? "app_link" : "qr";
|
|
1417
|
-
|
|
1579
|
+
const requestId = started.request_id;
|
|
1580
|
+
const qrBase = `${issuer}/pair/${encodeURIComponent(requestId)}/qr.svg`;
|
|
1581
|
+
const animated = !useAppLink && started.display !== "legacy";
|
|
1582
|
+
const qrRefreshSeconds = !animated ? void 0 : typeof started.qr_refresh_seconds === "number" && started.qr_refresh_seconds > 0 ? started.qr_refresh_seconds : DEFAULT_QR_REFRESH_SECONDS;
|
|
1583
|
+
surface.requestId = requestId;
|
|
1418
1584
|
surface.pairUrl = started.pair_url;
|
|
1419
|
-
surface.qrUrl =
|
|
1585
|
+
surface.qrUrl = qrBase;
|
|
1420
1586
|
surface.appLink = useAppLink;
|
|
1421
|
-
const
|
|
1587
|
+
const withSurface = (s) => ({
|
|
1588
|
+
...s,
|
|
1422
1589
|
pairUrl: surface.pairUrl,
|
|
1423
1590
|
qrUrl: surface.qrUrl,
|
|
1591
|
+
qrRefreshSeconds,
|
|
1424
1592
|
appLink: useAppLink,
|
|
1425
1593
|
cancel
|
|
1426
|
-
};
|
|
1427
|
-
|
|
1594
|
+
});
|
|
1595
|
+
let lastPolled = { status: "pending", expiresIn: started.expires_in };
|
|
1596
|
+
const initial = withSurface(lastPolled);
|
|
1428
1597
|
options.onState?.(initial);
|
|
1429
1598
|
if ((options.pairingUI ?? "modal") === "modal" && !useAppLink) {
|
|
1430
1599
|
modal = mountPairingModal(initial, {
|
|
@@ -1437,18 +1606,53 @@ function startLogin(options) {
|
|
|
1437
1606
|
if (useAppLink && typeof window !== "undefined") {
|
|
1438
1607
|
window.location.assign(started.pair_url);
|
|
1439
1608
|
}
|
|
1609
|
+
if (qrRefreshSeconds !== void 0) {
|
|
1610
|
+
const periodMs = qrRefreshSeconds * 1e3;
|
|
1611
|
+
let due = Date.now() + periodMs;
|
|
1612
|
+
let timer;
|
|
1613
|
+
let stopped = false;
|
|
1614
|
+
const emit = () => {
|
|
1615
|
+
timer = void 0;
|
|
1616
|
+
surface.qrUrl = `${qrBase}?t=${Date.now()}`;
|
|
1617
|
+
const next = withSurface(lastPolled);
|
|
1618
|
+
modal?.update(next);
|
|
1619
|
+
options.onState?.(next);
|
|
1620
|
+
if (stopped) return;
|
|
1621
|
+
due = Date.now() + periodMs;
|
|
1622
|
+
timer = setTimeout(emit, periodMs);
|
|
1623
|
+
};
|
|
1624
|
+
const onVisible = () => {
|
|
1625
|
+
if (document.visibilityState === "visible" && timer !== void 0 && Date.now() >= due) {
|
|
1626
|
+
clearTimeout(timer);
|
|
1627
|
+
emit();
|
|
1628
|
+
}
|
|
1629
|
+
};
|
|
1630
|
+
const hasDocument = typeof document !== "undefined";
|
|
1631
|
+
if (hasDocument) document.addEventListener("visibilitychange", onVisible);
|
|
1632
|
+
stopRefresh = () => {
|
|
1633
|
+
stopped = true;
|
|
1634
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1635
|
+
timer = void 0;
|
|
1636
|
+
if (hasDocument) document.removeEventListener("visibilitychange", onVisible);
|
|
1637
|
+
stopRefresh = () => {
|
|
1638
|
+
};
|
|
1639
|
+
};
|
|
1640
|
+
timer = setTimeout(emit, Math.max(0, due - Date.now()));
|
|
1641
|
+
}
|
|
1440
1642
|
code = await pollUntilApproved(
|
|
1441
1643
|
issuer,
|
|
1442
|
-
|
|
1644
|
+
requestId,
|
|
1443
1645
|
(s) => {
|
|
1444
|
-
|
|
1646
|
+
lastPolled = s;
|
|
1647
|
+
if (s.status !== "pending") stopRefresh();
|
|
1648
|
+
const next = withSurface(s);
|
|
1445
1649
|
modal?.update(next);
|
|
1446
1650
|
options.onState?.(next);
|
|
1447
1651
|
},
|
|
1448
1652
|
controller.signal
|
|
1449
1653
|
);
|
|
1450
1654
|
}
|
|
1451
|
-
|
|
1655
|
+
teardown();
|
|
1452
1656
|
if (flow === "auth-code") {
|
|
1453
1657
|
const response2 = {
|
|
1454
1658
|
code,
|
|
@@ -1473,7 +1677,7 @@ function startLogin(options) {
|
|
|
1473
1677
|
};
|
|
1474
1678
|
return response;
|
|
1475
1679
|
} catch (e) {
|
|
1476
|
-
|
|
1680
|
+
teardown();
|
|
1477
1681
|
if (e instanceof DOMException && e.name === "AbortError") throw e;
|
|
1478
1682
|
if (e instanceof OAuthFlowError || e instanceof FlowAbandonedError) throw e;
|
|
1479
1683
|
throw new FlowAbandonedError({
|
|
@@ -1506,6 +1710,7 @@ function startLogin(options) {
|
|
|
1506
1710
|
0 && (module.exports = {
|
|
1507
1711
|
DEFAULT_ISSUER,
|
|
1508
1712
|
DEFAULT_PAIRING_TIMEOUT_MS,
|
|
1713
|
+
DEFAULT_QR_REFRESH_SECONDS,
|
|
1509
1714
|
FlowAbandonedError,
|
|
1510
1715
|
OAuthFlowError,
|
|
1511
1716
|
POLL_INTERVAL_ENROLLING_MS,
|