@zoreal/oauth2-js 0.1.8 → 0.1.10
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 +40 -10
- package/dist/index.cjs +95 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +99 -16
- package/dist/index.d.ts +99 -16
- package/dist/index.js +94 -17
- 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
|
|
|
@@ -309,7 +322,6 @@ key, `user` a presence/unlock gesture, `face` a face biometric. `zoreal.live` is
|
|
|
309
322
|
const handle = startLogin({
|
|
310
323
|
clientId: 'ast_your_asset_id',
|
|
311
324
|
acr_values: 'zoreal.live', // the app now makes the holder pass a face capture
|
|
312
|
-
onState: (s) => renderPairing(s),
|
|
313
325
|
});
|
|
314
326
|
```
|
|
315
327
|
|
|
@@ -366,6 +378,9 @@ enforced where enforcement counts: on your backend, against the verified token.
|
|
|
366
378
|
| `generateVerifier()` / `challengeS256(v)` / `generateState()` | PKCE and state material, S256 only |
|
|
367
379
|
| `unsafeClaims(idToken)` | reads claims without verifying. Convenience only; verification happens server-side |
|
|
368
380
|
| `isMobileUserAgent()` | whether this user agent gets the app link rather than a QR |
|
|
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 |
|
|
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 |
|
|
369
384
|
|
|
370
385
|
Errors: `OAuthFlowError` (the provider refused; `error` is the OAuth code,
|
|
371
386
|
`description` is the provider's reason verbatim) and `FlowAbandonedError` (a
|
|
@@ -373,10 +388,17 @@ human outcome: `reason.type` is `request_denied`, `request_expired`,
|
|
|
373
388
|
`enrolment_abandoned`, or `unknown` for failures that never reached the
|
|
374
389
|
provider). `cancel()` rejects with a `DOMException` named `AbortError`.
|
|
375
390
|
|
|
391
|
+
`startLogin` options controlling the built-in modal: `pairingUI`
|
|
392
|
+
(`'modal'` default, `'none'` to render your own), `theme` (`'auto'` default,
|
|
393
|
+
`'light'`, `'dark'`), `pairingTimeoutMs` (120000 default), and `locale`, which
|
|
394
|
+
is sent to the provider AND picks the modal's own language. See
|
|
395
|
+
[The pairing modal](#the-pairing-modal).
|
|
396
|
+
|
|
376
397
|
All types are exported: `PairingState`, `ZorealCredentialResponse`,
|
|
377
398
|
`ZorealCodeResponse`, `StartLoginOptions`, `BrowserDirectLoginOptions`,
|
|
378
399
|
`AuthCodeLoginOptions`, `LoginHandle`, `ErrorCode`, `NonOAuthError`,
|
|
379
|
-
`SelectBy`, `AcrValue`,
|
|
400
|
+
`SelectBy`, `AcrValue`, `PairingUI`, `PairDisplay`, `ZorealTheme`,
|
|
401
|
+
`PairingModalHandle`, `PairingModalOptions`, and the wire shapes.
|
|
380
402
|
|
|
381
403
|
## Error reference
|
|
382
404
|
|
|
@@ -461,7 +483,10 @@ export function mountZorealButton(root: HTMLElement) {
|
|
|
461
483
|
}
|
|
462
484
|
if (s.qrUrl) {
|
|
463
485
|
const img = document.createElement('img');
|
|
464
|
-
|
|
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;
|
|
465
490
|
img.alt = 'Scan with the ZOREAL ID app';
|
|
466
491
|
panel.append(img);
|
|
467
492
|
}
|
|
@@ -482,6 +507,9 @@ export function mountZorealButton(root: HTMLElement) {
|
|
|
482
507
|
flow: 'auth-code',
|
|
483
508
|
clientId: 'ast_your_asset_id',
|
|
484
509
|
scope: 'openid email profile.name',
|
|
510
|
+
// This example draws its own panel, so it opts out of the built-in
|
|
511
|
+
// modal. Drop these two lines and delete renderPairing to use it.
|
|
512
|
+
pairingUI: 'none',
|
|
485
513
|
onState: renderPairing,
|
|
486
514
|
});
|
|
487
515
|
|
|
@@ -598,7 +626,9 @@ And the template renders the state:
|
|
|
598
626
|
The same shape ports to Svelte (a store fed by `onState`) or Angular (a
|
|
599
627
|
service exposing an observable). The rules a wrapper must keep:
|
|
600
628
|
|
|
601
|
-
- 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.
|
|
602
632
|
- Call `cancel()` on unmount or navigation. Do not add your own retry loop:
|
|
603
633
|
the poll cadence is fixed because over-polling cancels the request
|
|
604
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.10";
|
|
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 (; ; ) {
|
|
@@ -1305,6 +1311,29 @@ function mountPairingModal(state, options) {
|
|
|
1305
1311
|
scrim.appendChild(card);
|
|
1306
1312
|
const serverMs = typeof state.expiresIn === "number" ? state.expiresIn * 1e3 : Infinity;
|
|
1307
1313
|
const deadline = Date.now() + Math.min(timeoutMs, serverMs);
|
|
1314
|
+
let shown = state.qrUrl;
|
|
1315
|
+
let loading = null;
|
|
1316
|
+
const spent = () => qr.dataset.spent === "true";
|
|
1317
|
+
const showFrame = (url) => {
|
|
1318
|
+
if (url === shown || spent()) return;
|
|
1319
|
+
if (!shown) {
|
|
1320
|
+
qr.src = url;
|
|
1321
|
+
shown = url;
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
const next = new Image();
|
|
1325
|
+
loading = next;
|
|
1326
|
+
next.onload = () => {
|
|
1327
|
+
if (loading !== next || spent()) return;
|
|
1328
|
+
loading = null;
|
|
1329
|
+
qr.src = url;
|
|
1330
|
+
shown = url;
|
|
1331
|
+
};
|
|
1332
|
+
next.onerror = () => {
|
|
1333
|
+
if (loading === next) loading = null;
|
|
1334
|
+
};
|
|
1335
|
+
next.src = url;
|
|
1336
|
+
};
|
|
1308
1337
|
const paint = (s) => {
|
|
1309
1338
|
const settled = s.status === "claimed" || s.status === "enrolling";
|
|
1310
1339
|
title.textContent = settled ? t.titleApprove : t.title;
|
|
@@ -1312,7 +1341,7 @@ function mountPairingModal(state, options) {
|
|
|
1312
1341
|
statusLabel.textContent = settled ? t.waitingApproval : t.waiting;
|
|
1313
1342
|
qr.dataset.spent = String(settled);
|
|
1314
1343
|
overlay.style.display = settled ? "" : "none";
|
|
1315
|
-
if (s.qrUrl
|
|
1344
|
+
if (s.qrUrl) showFrame(s.qrUrl);
|
|
1316
1345
|
};
|
|
1317
1346
|
const tick = () => {
|
|
1318
1347
|
const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1e3));
|
|
@@ -1329,6 +1358,7 @@ function mountPairingModal(state, options) {
|
|
|
1329
1358
|
const close = () => {
|
|
1330
1359
|
if (closed) return;
|
|
1331
1360
|
closed = true;
|
|
1361
|
+
loading = null;
|
|
1332
1362
|
window.clearInterval(interval);
|
|
1333
1363
|
document.removeEventListener("keydown", onKey);
|
|
1334
1364
|
document.body.style.overflow = previousOverflow;
|
|
@@ -1378,10 +1408,15 @@ function startLogin(options) {
|
|
|
1378
1408
|
const flow = options.flow ?? "browser-direct";
|
|
1379
1409
|
const issuer = options.issuer ?? DEFAULT_ISSUER;
|
|
1380
1410
|
const controller = new AbortController();
|
|
1411
|
+
const useAppLink = options.display === "link" || options.display !== "qr" && isMobileUserAgent();
|
|
1381
1412
|
const surface = {};
|
|
1382
1413
|
const cancel = () => controller.abort();
|
|
1383
1414
|
let modal = null;
|
|
1384
|
-
|
|
1415
|
+
let stopRefresh = () => {
|
|
1416
|
+
};
|
|
1417
|
+
controller.signal.addEventListener("abort", () => stopRefresh());
|
|
1418
|
+
const teardown = () => {
|
|
1419
|
+
stopRefresh();
|
|
1385
1420
|
modal?.close();
|
|
1386
1421
|
modal = null;
|
|
1387
1422
|
};
|
|
@@ -1402,7 +1437,8 @@ function startLogin(options) {
|
|
|
1402
1437
|
acr_values: Array.isArray(options.acr_values) ? options.acr_values.join(" ") : options.acr_values,
|
|
1403
1438
|
max_age: options.max_age,
|
|
1404
1439
|
prompt: options.prompt,
|
|
1405
|
-
locale: options.locale
|
|
1440
|
+
locale: options.locale,
|
|
1441
|
+
display: useAppLink ? "link" : "qr"
|
|
1406
1442
|
},
|
|
1407
1443
|
controller.signal
|
|
1408
1444
|
);
|
|
@@ -1412,19 +1448,25 @@ function startLogin(options) {
|
|
|
1412
1448
|
code = started.code;
|
|
1413
1449
|
selectBy = "session";
|
|
1414
1450
|
} else {
|
|
1415
|
-
const useAppLink = options.display === "link" || options.display !== "qr" && isMobileUserAgent();
|
|
1416
1451
|
selectBy = useAppLink ? "app_link" : "qr";
|
|
1417
|
-
|
|
1452
|
+
const requestId = started.request_id;
|
|
1453
|
+
const qrBase = `${issuer}/pair/${encodeURIComponent(requestId)}/qr.svg`;
|
|
1454
|
+
const animated = !useAppLink && started.display !== "legacy";
|
|
1455
|
+
const qrRefreshSeconds = !animated ? void 0 : typeof started.qr_refresh_seconds === "number" && started.qr_refresh_seconds > 0 ? started.qr_refresh_seconds : DEFAULT_QR_REFRESH_SECONDS;
|
|
1456
|
+
surface.requestId = requestId;
|
|
1418
1457
|
surface.pairUrl = started.pair_url;
|
|
1419
|
-
surface.qrUrl =
|
|
1458
|
+
surface.qrUrl = qrBase;
|
|
1420
1459
|
surface.appLink = useAppLink;
|
|
1421
|
-
const
|
|
1460
|
+
const withSurface = (s) => ({
|
|
1461
|
+
...s,
|
|
1422
1462
|
pairUrl: surface.pairUrl,
|
|
1423
1463
|
qrUrl: surface.qrUrl,
|
|
1464
|
+
qrRefreshSeconds,
|
|
1424
1465
|
appLink: useAppLink,
|
|
1425
1466
|
cancel
|
|
1426
|
-
};
|
|
1427
|
-
|
|
1467
|
+
});
|
|
1468
|
+
let lastPolled = { status: "pending", expiresIn: started.expires_in };
|
|
1469
|
+
const initial = withSurface(lastPolled);
|
|
1428
1470
|
options.onState?.(initial);
|
|
1429
1471
|
if ((options.pairingUI ?? "modal") === "modal" && !useAppLink) {
|
|
1430
1472
|
modal = mountPairingModal(initial, {
|
|
@@ -1437,18 +1479,53 @@ function startLogin(options) {
|
|
|
1437
1479
|
if (useAppLink && typeof window !== "undefined") {
|
|
1438
1480
|
window.location.assign(started.pair_url);
|
|
1439
1481
|
}
|
|
1482
|
+
if (qrRefreshSeconds !== void 0) {
|
|
1483
|
+
const periodMs = qrRefreshSeconds * 1e3;
|
|
1484
|
+
let due = Date.now() + periodMs;
|
|
1485
|
+
let timer;
|
|
1486
|
+
let stopped = false;
|
|
1487
|
+
const emit = () => {
|
|
1488
|
+
timer = void 0;
|
|
1489
|
+
surface.qrUrl = `${qrBase}?t=${Date.now()}`;
|
|
1490
|
+
const next = withSurface(lastPolled);
|
|
1491
|
+
modal?.update(next);
|
|
1492
|
+
options.onState?.(next);
|
|
1493
|
+
if (stopped) return;
|
|
1494
|
+
due = Date.now() + periodMs;
|
|
1495
|
+
timer = setTimeout(emit, periodMs);
|
|
1496
|
+
};
|
|
1497
|
+
const onVisible = () => {
|
|
1498
|
+
if (document.visibilityState === "visible" && timer !== void 0 && Date.now() >= due) {
|
|
1499
|
+
clearTimeout(timer);
|
|
1500
|
+
emit();
|
|
1501
|
+
}
|
|
1502
|
+
};
|
|
1503
|
+
const hasDocument = typeof document !== "undefined";
|
|
1504
|
+
if (hasDocument) document.addEventListener("visibilitychange", onVisible);
|
|
1505
|
+
stopRefresh = () => {
|
|
1506
|
+
stopped = true;
|
|
1507
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1508
|
+
timer = void 0;
|
|
1509
|
+
if (hasDocument) document.removeEventListener("visibilitychange", onVisible);
|
|
1510
|
+
stopRefresh = () => {
|
|
1511
|
+
};
|
|
1512
|
+
};
|
|
1513
|
+
timer = setTimeout(emit, Math.max(0, due - Date.now()));
|
|
1514
|
+
}
|
|
1440
1515
|
code = await pollUntilApproved(
|
|
1441
1516
|
issuer,
|
|
1442
|
-
|
|
1517
|
+
requestId,
|
|
1443
1518
|
(s) => {
|
|
1444
|
-
|
|
1519
|
+
lastPolled = s;
|
|
1520
|
+
if (s.status !== "pending") stopRefresh();
|
|
1521
|
+
const next = withSurface(s);
|
|
1445
1522
|
modal?.update(next);
|
|
1446
1523
|
options.onState?.(next);
|
|
1447
1524
|
},
|
|
1448
1525
|
controller.signal
|
|
1449
1526
|
);
|
|
1450
1527
|
}
|
|
1451
|
-
|
|
1528
|
+
teardown();
|
|
1452
1529
|
if (flow === "auth-code") {
|
|
1453
1530
|
const response2 = {
|
|
1454
1531
|
code,
|
|
@@ -1473,7 +1550,7 @@ function startLogin(options) {
|
|
|
1473
1550
|
};
|
|
1474
1551
|
return response;
|
|
1475
1552
|
} catch (e) {
|
|
1476
|
-
|
|
1553
|
+
teardown();
|
|
1477
1554
|
if (e instanceof DOMException && e.name === "AbortError") throw e;
|
|
1478
1555
|
if (e instanceof OAuthFlowError || e instanceof FlowAbandonedError) throw e;
|
|
1479
1556
|
throw new FlowAbandonedError({
|
|
@@ -1506,6 +1583,7 @@ function startLogin(options) {
|
|
|
1506
1583
|
0 && (module.exports = {
|
|
1507
1584
|
DEFAULT_ISSUER,
|
|
1508
1585
|
DEFAULT_PAIRING_TIMEOUT_MS,
|
|
1586
|
+
DEFAULT_QR_REFRESH_SECONDS,
|
|
1509
1587
|
FlowAbandonedError,
|
|
1510
1588
|
OAuthFlowError,
|
|
1511
1589
|
POLL_INTERVAL_ENROLLING_MS,
|