@zoreal/oauth2-js 0.1.2 → 0.1.4
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 +342 -13
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -3
- package/dist/index.d.ts +13 -3
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,13 +15,6 @@ React package is what that wrapper looks like when it is finished.
|
|
|
15
15
|
your wrapper or plain JS the UI: render what onState carries
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
## Status
|
|
19
|
-
|
|
20
|
-
Early release. The package implements wire protocol v1. The hosted ZOREAL
|
|
21
|
-
login service is still rolling out, so treat this as a preview: the API is
|
|
22
|
-
stable, but end-to-end sign-in against production is not available everywhere
|
|
23
|
-
yet. This note is removed once the service is generally available.
|
|
24
|
-
|
|
25
18
|
## Install
|
|
26
19
|
|
|
27
20
|
```sh
|
|
@@ -31,6 +24,47 @@ npm install @zoreal/oauth2-js
|
|
|
31
24
|
Zero runtime dependencies. ESM and CJS. Browser APIs only (`fetch`,
|
|
32
25
|
`crypto.subtle`); any evergreen browser has everything it needs.
|
|
33
26
|
|
|
27
|
+
## Getting your credentials
|
|
28
|
+
|
|
29
|
+
`clientId` is the only credential this package needs, and it comes from a ZOREAL
|
|
30
|
+
**asset**.
|
|
31
|
+
|
|
32
|
+
1. Create an account at **https://zoreal.com** and open **Assets**.
|
|
33
|
+
2. **Create an asset** — a *website* (a domain you own) or an *app bundle* (a
|
|
34
|
+
reverse-DNS bundle id). An asset is the thing users log in to; its token is
|
|
35
|
+
your `clientId` and it looks like `ast_...`.
|
|
36
|
+
3. On the asset, open the **OAuth2** tab and set:
|
|
37
|
+
- the **JavaScript origins** this page is served from and the **redirect
|
|
38
|
+
URIs** your app uses — requests from anything not registered are rejected,
|
|
39
|
+
which is the core control,
|
|
40
|
+
- the **scopes** the client may request (see the catalogue below); a request
|
|
41
|
+
for a scope not on the list is refused at the pairing step,
|
|
42
|
+
- **client authentication** — for the auth-code flow, generate a **client
|
|
43
|
+
secret** or register a **JWKS** on the asset. That credential lives on your
|
|
44
|
+
backend and never comes here. Browser-direct is a public client: PKCE
|
|
45
|
+
alone, no secret.
|
|
46
|
+
4. A website asset must **verify its domain** (a DNS or meta-tag proof, shown in
|
|
47
|
+
the dashboard) before it can request personal-data scopes or sign users in;
|
|
48
|
+
the verified domain is what your users' `sub` is pairwise against.
|
|
49
|
+
|
|
50
|
+
`clientId` is public by design — it ships in your frontend, and this package
|
|
51
|
+
takes nothing else. No client secret has a home in the browser (see *No secret
|
|
52
|
+
has a home here*, below).
|
|
53
|
+
|
|
54
|
+
### There is no test-identity sandbox — and that is deliberate
|
|
55
|
+
|
|
56
|
+
ZOREAL **never issues fake or sandbox humans**: a pool of test identities would
|
|
57
|
+
be a fraud vector against the exact thing the product proves. So you always
|
|
58
|
+
authenticate **real** ZOREAL IDs.
|
|
59
|
+
|
|
60
|
+
To develop and test, **create a free ZOREAL ID for yourself** (enrol in the
|
|
61
|
+
ZOREAL ID app) and sign in with it. Mark your asset's environment **sandbox** in
|
|
62
|
+
the dashboard while building — a sandbox asset may register `http://localhost`
|
|
63
|
+
origins and redirect URIs that a production asset may not — and flip it to
|
|
64
|
+
production when you ship. The identities are real either way; only the allowed
|
|
65
|
+
origins differ. There is no mock provider and no hosted test issuer to point at:
|
|
66
|
+
the issuer is `https://id.zoreal.com` in every environment.
|
|
67
|
+
|
|
34
68
|
## Two flows: pick by whether you need the user's details
|
|
35
69
|
|
|
36
70
|
- **You have a backend and want the user's email or name** (most apps): use
|
|
@@ -142,6 +176,128 @@ Auth-code:
|
|
|
142
176
|
URL, which is a credential in every access log on the path. `startLogin`
|
|
143
177
|
throws rather than doing that.
|
|
144
178
|
|
|
179
|
+
## Scopes and claims
|
|
180
|
+
|
|
181
|
+
Scopes are the `scope` string you pass to `startLogin` (always starting with
|
|
182
|
+
`openid`), consented to by the holder, and pre-authorized on your asset. What
|
|
183
|
+
each grants and where it is delivered:
|
|
184
|
+
|
|
185
|
+
| Scope | Claims | Delivered in | Tier | Requires |
|
|
186
|
+
|---|---|---|---|---|
|
|
187
|
+
| `openid` | `sub`, `iss`, `aud`, `exp`, `iat`, `nonce`, `auth_time`, `acr`, `amr`, and the assurance block | ID token | A | any client |
|
|
188
|
+
| `zoreal.age` | `age_over_13/16/18/21/65` booleans — only the thresholds you registered, never an age or birthdate | ID token | A | any client |
|
|
189
|
+
| `zoreal.nationality` | `nationality` (ISO 3166-1 alpha-3) | ID token | A | any client |
|
|
190
|
+
| `email` | `email`, `email_verified` | `/userinfo` | B | confidential client + verified domain |
|
|
191
|
+
| `profile.name` | `name`, `given_name`, `family_name` | `/userinfo` | B | confidential client + verified domain |
|
|
192
|
+
| `profile.birthdate` | `birthdate` (full ISO 8601 date) | `/userinfo` | B | confidential client + verified domain |
|
|
193
|
+
| `profile.document` | `document_type`, `document_number`, `issuing_country`, `document_expires_on` | `/userinfo` | B | confidential client + verified domain |
|
|
194
|
+
| `profile.portrait` | `portrait` (the chip's facial image; GDPR Article 9 data) | `/userinfo` | C | confidential client + verified domain — *registrable but not served yet* |
|
|
195
|
+
|
|
196
|
+
- **Tier A** rides in the ID token and is available to every client, so the
|
|
197
|
+
browser-direct flow can use it with no backend at all. **Tier B and C** are
|
|
198
|
+
personal data, served only from `/userinfo` to a confidential client on a
|
|
199
|
+
domain you have verified, and never placed in a browser token — which is why
|
|
200
|
+
any scope beyond Tier A needs `flow: 'auth-code'` and your backend. A public
|
|
201
|
+
client that asks for one is refused at the pairing step with `invalid_scope`.
|
|
202
|
+
- **Age thresholds are a fixed set** — 13, 16, 18, 21, 65 — that you register on
|
|
203
|
+
the asset. The `age_over_*` claim for a threshold you did not register is
|
|
204
|
+
simply absent (no claim was minted), which a backend reads as `null`/`nil`
|
|
205
|
+
rather than `false`.
|
|
206
|
+
|
|
207
|
+
## Assurance levels — `acr` and requiring a liveness check
|
|
208
|
+
|
|
209
|
+
### What `acr` is
|
|
210
|
+
|
|
211
|
+
`acr` is an OpenID Connect standard claim — *Authentication Context Class
|
|
212
|
+
Reference*. It is a string in the ID token that says **how strongly this login
|
|
213
|
+
was authenticated**. `sub` tells you *who* (a stable, pairwise identifier for
|
|
214
|
+
this person at your site); `acr` tells you *how sure ZOREAL is that the person is
|
|
215
|
+
really there for this login*. A stolen, unlocked phone can still produce a `sub`;
|
|
216
|
+
it cannot produce a fresh `zoreal.live`.
|
|
217
|
+
|
|
218
|
+
This core is the **request** side of `acr`: you ask for a level via
|
|
219
|
+
`startLogin`, which decides what the holder's ZOREAL ID app makes them do.
|
|
220
|
+
Whether it was reached is decided by the signed token and checked on your
|
|
221
|
+
backend.
|
|
222
|
+
|
|
223
|
+
### The three levels
|
|
224
|
+
|
|
225
|
+
Weakest to strongest. `acr` reports what actually happened, never what was asked.
|
|
226
|
+
|
|
227
|
+
| `acr` | What the holder did | `amr` | Proves | Does **not** prove |
|
|
228
|
+
|---|---|---|---|---|
|
|
229
|
+
| `zoreal.session` | Nothing — a returning holder resumed silently from an existing ZOREAL session, no phone interaction | `[]` | Continuity | Presence |
|
|
230
|
+
| `zoreal.device` | Approved on their enrolled phone: a secure-element key signature released by a local biometric/passcode unlock | `["hwk","user"]` | Possession of the enrolled device **and** a local unlock | That a live face was captured for *this* login |
|
|
231
|
+
| `zoreal.live` | The above **plus** a fresh face capture this login — a flash-plus-zoom video scored for presentation attacks and screen replay, matched 1:1 to the government document read at enrolment | `["hwk","face","user"]` | A live, real, unique human, verified to be the enrolled person, **at the moment of this login** | — (strongest) |
|
|
232
|
+
|
|
233
|
+
`amr` (*Authentication Methods References*) lists the factors: `hwk` a hardware
|
|
234
|
+
key, `user` a presence/unlock gesture, `face` a face biometric. `zoreal.live` is
|
|
235
|
+
`zoreal.device` with `face` added. The default is `zoreal.device`.
|
|
236
|
+
|
|
237
|
+
### When to request which
|
|
238
|
+
|
|
239
|
+
- **`zoreal.device`** (the default): a forum, a community, a normal login. Pass
|
|
240
|
+
no `acr_values`.
|
|
241
|
+
- **`zoreal.live`**: a bank onboarding, a high-value transaction, an age-gated
|
|
242
|
+
purchase, a first login, a "confirm it is really you" step.
|
|
243
|
+
- **`zoreal.session`** is never *requested*; it is the silent convenience re-auth
|
|
244
|
+
(`prompt: 'none'`) a returning holder gets at a consented site.
|
|
245
|
+
|
|
246
|
+
### Requesting it here
|
|
247
|
+
|
|
248
|
+
`acr_values` is an option on `startLogin`, typed `AcrValue | AcrValue[]` where
|
|
249
|
+
`AcrValue = 'zoreal.live' | 'zoreal.device' | 'zoreal.session'`.
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
const handle = startLogin({
|
|
253
|
+
clientId: 'ast_your_asset_id',
|
|
254
|
+
acr_values: 'zoreal.live', // the app now makes the holder pass a face capture
|
|
255
|
+
onState: (s) => renderPairing(s),
|
|
256
|
+
});
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
In browser-direct mode the resolved level is on the credential response as
|
|
260
|
+
`acr`, parsed from the ID token; the token stays the authority.
|
|
261
|
+
|
|
262
|
+
### Requesting is not verifying — the rule that matters
|
|
263
|
+
|
|
264
|
+
`acr_values` here is **advisory**: it shapes what the holder is asked to do, and
|
|
265
|
+
proves nothing on its own, because a browser is attacker-controlled. The proof is
|
|
266
|
+
the **signed `acr` claim**, minted by ZOREAL, verified on your **backend** — the
|
|
267
|
+
ZOREAL backend libraries (`zoreal-oauth2` for Ruby and its siblings for Node,
|
|
268
|
+
Python, PHP, Go, JVM and .NET) take a required-acr argument at exchange and
|
|
269
|
+
refuse a token below the level. A relying party that requests `zoreal.live` but
|
|
270
|
+
never verifies the claim has checked nothing.
|
|
271
|
+
|
|
272
|
+
### `acr` versus the assurance block
|
|
273
|
+
|
|
274
|
+
`acr` grades *this login event*. The assurance block in the token (uniqueness
|
|
275
|
+
basis, verification month, chip-liveness, trust tier, key protection) describes
|
|
276
|
+
the *identity behind it*. One is about now; the other about who they are. A
|
|
277
|
+
high-value flow wants both.
|
|
278
|
+
|
|
279
|
+
## The assurance block
|
|
280
|
+
|
|
281
|
+
The ID token carries a `zoreal` claim — the **assurance block** — describing the
|
|
282
|
+
strength of the *identity* behind this login, distinct from `acr`, which grades
|
|
283
|
+
the *login event*. In browser-direct mode you can read it for display with
|
|
284
|
+
`unsafeClaims(credential).zoreal` (convenience only — the token is the authority
|
|
285
|
+
once your backend has verified it); in the auth-code flow your backend reads it
|
|
286
|
+
from the verified token. Its keys and their value sets:
|
|
287
|
+
|
|
288
|
+
| Key | Values | Meaning |
|
|
289
|
+
|---|---|---|
|
|
290
|
+
| `uniqueness` | `personal_number` \| `document` \| `none` | The anchor the holder is deduplicated on. `personal_number` (a national number from the chip) is strongest; `none` means no reliable anchor |
|
|
291
|
+
| `verified_on` | `"YYYY-MM"` | The month the underlying document was verified. Quantised to a month on purpose — a day-precision date is a cross-site correlator |
|
|
292
|
+
| `chip_liveness_proven` | `true` \| `false` | Whether the passport chip's active-authentication challenge was proven (a genuine chip, not a clone) |
|
|
293
|
+
| `trust_tier` | `high` \| `standard` | `high` when `chip_liveness_proven`, else `standard` |
|
|
294
|
+
| `key_protection` | `secure_enclave` \| `strongbox` \| `tee` \| `software` | How the holder's device key is protected. `software` means no hardware attestation |
|
|
295
|
+
|
|
296
|
+
A high-value flow usually pairs `acr_values: 'zoreal.live'` (fresh presence)
|
|
297
|
+
with a check on the assurance block (identity strength) — e.g. requiring
|
|
298
|
+
`uniqueness === 'personal_number'` and `trust_tier === 'high'`. Both checks are
|
|
299
|
+
enforced where enforcement counts: on your backend, against the verified token.
|
|
300
|
+
|
|
145
301
|
## API
|
|
146
302
|
|
|
147
303
|
| Export | What it does |
|
|
@@ -165,6 +321,157 @@ All types are exported: `PairingState`, `ZorealCredentialResponse`,
|
|
|
165
321
|
`AuthCodeLoginOptions`, `LoginHandle`, `ErrorCode`, `NonOAuthError`,
|
|
166
322
|
`SelectBy`, `AcrValue`, and the wire shapes.
|
|
167
323
|
|
|
324
|
+
## Error reference
|
|
325
|
+
|
|
326
|
+
### At `/token`
|
|
327
|
+
|
|
328
|
+
The code exchange can fail with these OAuth codes. In **browser-direct** mode
|
|
329
|
+
this package makes the `/token` call for you (`exchangeCode`), and a failure
|
|
330
|
+
arrives as an `OAuthFlowError` whose `error` is one of these. In **auth-code**
|
|
331
|
+
mode the `/token` call is your backend's, and it sees the same codes there.
|
|
332
|
+
|
|
333
|
+
| `error` | Cause | Retryable? |
|
|
334
|
+
|---|---|---|
|
|
335
|
+
| `invalid_grant` | The code is spent — unknown, expired (60s), already used, PKCE mismatch, or the asset's domain verification lapsed mid-flow | No. Start a **new** login; the code cannot be reused |
|
|
336
|
+
| `invalid_request` | Client authentication failed — wrong secret, a bad `private_key_jwt` assertion, or `tls_client_auth` (not accepted at `/token` yet). A backend-side concern; browser-direct is a public client and never authenticates | No. Fix the backend's client configuration |
|
|
337
|
+
| `unsupported_grant_type` | Something other than `authorization_code` reached `/token` | No. A bug |
|
|
338
|
+
|
|
339
|
+
### Before the exchange — surfaced in the browser
|
|
340
|
+
|
|
341
|
+
These come back from the pairing step, before any code exists, and are what your
|
|
342
|
+
UI handles directly:
|
|
343
|
+
|
|
344
|
+
| Where | Code / reason | This package | Meaning |
|
|
345
|
+
|---|---|---|---|
|
|
346
|
+
| `/pair` | `invalid_scope` | `OAuthFlowError` | A scope not on the asset's allowed list, or a Tier B scope from a public client |
|
|
347
|
+
| `/pair` | `invalid_request` | `OAuthFlowError` | Missing PKCE/nonce, an unverified sector, an unregistered `redirect_uri`, or an unknown `acr_values` |
|
|
348
|
+
| `/pair` | `login_required` | `OAuthFlowError` | `prompt: 'none'` with no silent session to resume — the expected quiet outcome, not a failure |
|
|
349
|
+
| pairing | `request_denied` | `FlowAbandonedError` | The holder declined in their ZOREAL ID app — **not an error to alarm on**; offer to try again |
|
|
350
|
+
| pairing | `request_expired` | `FlowAbandonedError` | The pairing window elapsed, or a required liveness the device could not meet — offer to try again |
|
|
351
|
+
|
|
352
|
+
### This package's error classes
|
|
353
|
+
|
|
354
|
+
- **`OAuthFlowError`** — the provider refused. `error` is the OAuth code (an
|
|
355
|
+
`ErrorCode`), and `description` is the provider's own reason string. Render
|
|
356
|
+
`description` verbatim; it is the only signal that tells an integrator what to
|
|
357
|
+
fix (a refused package version arrives this way too).
|
|
358
|
+
- **`FlowAbandonedError`** — a *human* outcome, or a failure that never reached
|
|
359
|
+
the provider. `reason.type` is `request_denied`, `request_expired`,
|
|
360
|
+
`enrolment_abandoned`, or `unknown`, and `reason.description` carries the
|
|
361
|
+
provider's words when there are any. `request_denied` and `request_expired`
|
|
362
|
+
are the everyday cancel/timeout paths — treat them as "offer to try again",
|
|
363
|
+
not as faults to log at error level.
|
|
364
|
+
- **`AbortError`** — a `DOMException` named `AbortError`, thrown when *you* call
|
|
365
|
+
`handle.cancel()` (or the `cancel()` on a `PairingState`). It means the flow
|
|
366
|
+
was abandoned on purpose; check `e.name === 'AbortError'` and stay silent.
|
|
367
|
+
|
|
368
|
+
The two paths that are **not** failures are a user closing the dialog
|
|
369
|
+
(`AbortError`) and a holder declining (`FlowAbandonedError` with
|
|
370
|
+
`request_denied`). Everything a real integration should surface to the user as an
|
|
371
|
+
error is an `OAuthFlowError`, or the rare `FlowAbandonedError` of type `unknown`.
|
|
372
|
+
|
|
373
|
+
## A complete example
|
|
374
|
+
|
|
375
|
+
A whole "Continue with ZOREAL" control in plain TypeScript — no framework — that
|
|
376
|
+
runs the auth-code flow, shows the pairing UI from `onState`, and hands
|
|
377
|
+
`{ code, code_verifier, nonce }` to your backend. **Your backend is where the
|
|
378
|
+
login is actually verified**: it exchanges the code at `/token` with its client
|
|
379
|
+
authentication, checks the ID token's signature, `iss`, `aud`, `exp` and
|
|
380
|
+
`nonce` against the JWKS, and reads `/userinfo`. Nothing the browser resolves is
|
|
381
|
+
trusted until it has.
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
import {
|
|
385
|
+
startLogin,
|
|
386
|
+
OAuthFlowError,
|
|
387
|
+
FlowAbandonedError,
|
|
388
|
+
type PairingState,
|
|
389
|
+
} from '@zoreal/oauth2-js';
|
|
390
|
+
|
|
391
|
+
export function mountZorealButton(root: HTMLElement) {
|
|
392
|
+
const button = document.createElement('button');
|
|
393
|
+
button.textContent = 'Continue with ZOREAL';
|
|
394
|
+
const panel = document.createElement('div'); // holds the pairing UI
|
|
395
|
+
root.append(button, panel);
|
|
396
|
+
|
|
397
|
+
let handle: ReturnType<typeof startLogin> | null = null;
|
|
398
|
+
|
|
399
|
+
const renderPairing = (s: PairingState) => {
|
|
400
|
+
panel.replaceChildren();
|
|
401
|
+
if (s.appLink) {
|
|
402
|
+
panel.textContent = 'Opening the ZOREAL ID app…';
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (s.qrUrl) {
|
|
406
|
+
const img = document.createElement('img');
|
|
407
|
+
img.src = s.qrUrl; // provider-served; never draw your own QR of pairUrl
|
|
408
|
+
img.alt = 'Scan with the ZOREAL ID app';
|
|
409
|
+
panel.append(img);
|
|
410
|
+
}
|
|
411
|
+
const status = document.createElement('p');
|
|
412
|
+
status.textContent = s.status; // pending | claimed | approved | ...
|
|
413
|
+
panel.append(status);
|
|
414
|
+
if (s.cancel) {
|
|
415
|
+
const cancel = document.createElement('button');
|
|
416
|
+
cancel.textContent = 'Cancel';
|
|
417
|
+
cancel.onclick = () => s.cancel!();
|
|
418
|
+
panel.append(cancel);
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
button.onclick = async () => {
|
|
423
|
+
handle?.cancel(); // one flow at a time
|
|
424
|
+
handle = startLogin({
|
|
425
|
+
flow: 'auth-code',
|
|
426
|
+
clientId: 'ast_your_asset_id',
|
|
427
|
+
scope: 'openid email profile.name',
|
|
428
|
+
onState: renderPairing,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
const { code, code_verifier, nonce } = await handle.promise;
|
|
433
|
+
panel.replaceChildren();
|
|
434
|
+
|
|
435
|
+
// Post all three to YOUR backend over TLS. Protect this route with your
|
|
436
|
+
// framework's normal CSRF / same-origin controls — the ZOREAL nonce
|
|
437
|
+
// protects the token, not your endpoint. The backend verifies before it
|
|
438
|
+
// trusts, then establishes the session.
|
|
439
|
+
const res = await fetch('/api/auth/zoreal', {
|
|
440
|
+
method: 'POST',
|
|
441
|
+
headers: { 'Content-Type': 'application/json' },
|
|
442
|
+
body: JSON.stringify({ code, code_verifier, nonce }),
|
|
443
|
+
});
|
|
444
|
+
if (!res.ok) throw new Error('backend rejected the login');
|
|
445
|
+
window.location.assign('/dashboard');
|
|
446
|
+
} catch (e) {
|
|
447
|
+
panel.replaceChildren();
|
|
448
|
+
if (e instanceof DOMException && e.name === 'AbortError') {
|
|
449
|
+
return; // the user closed the dialog; say nothing
|
|
450
|
+
}
|
|
451
|
+
if (e instanceof FlowAbandonedError && e.reason.type === 'request_denied') {
|
|
452
|
+
panel.textContent = 'Login declined. Try again when you are ready.';
|
|
453
|
+
return; // a human outcome, not an error to alarm on
|
|
454
|
+
}
|
|
455
|
+
if (e instanceof FlowAbandonedError && e.reason.type === 'request_expired') {
|
|
456
|
+
panel.textContent = 'That took too long. Try again.';
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (e instanceof OAuthFlowError) {
|
|
460
|
+
panel.textContent = e.description ?? e.error; // provider's words, verbatim
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
panel.textContent = 'Something went wrong. Try again.';
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
For the no-backend case, swap `flow: 'auth-code'` for the default browser-direct
|
|
470
|
+
flow: `handle.promise` then resolves `{ credential }`, an ID token carrying only
|
|
471
|
+
`sub` and the proof of verification. It **still** has to be verified server-side
|
|
472
|
+
against the JWKS before you trust it — a token minted for someone else looks
|
|
473
|
+
identical in the browser.
|
|
474
|
+
|
|
168
475
|
## Writing a framework wrapper
|
|
169
476
|
|
|
170
477
|
A wrapper owns exactly two things: calling `startLogin` on the user's
|
|
@@ -274,6 +581,34 @@ origin:
|
|
|
274
581
|
while enrolling. The provider cancels an over-polling request rather than
|
|
275
582
|
throttling it, so polling faster kills the login it is trying to save.
|
|
276
583
|
|
|
584
|
+
## Security
|
|
585
|
+
|
|
586
|
+
Three things this package leans on, and where each stops:
|
|
587
|
+
|
|
588
|
+
- **The nonce binds the token to this login — it is not your CSRF token.** This
|
|
589
|
+
package generates a nonce, sends it with the pairing request, and resolves it
|
|
590
|
+
to you alongside the code. Handing it to your backend lets the backend confirm
|
|
591
|
+
the ID token was minted for *this* login rather than substituted. It does
|
|
592
|
+
**not** protect your own login route: guard `/api/auth/zoreal` (or wherever
|
|
593
|
+
you post the code) with your framework's normal CSRF / same-origin defences,
|
|
594
|
+
exactly as you would any endpoint that establishes a session.
|
|
595
|
+
- **PKCE is what proves the exchanger started the flow, not the nonce.** This
|
|
596
|
+
package generates the verifier, sends only its S256 challenge to `/pair`, and
|
|
597
|
+
keeps the verifier until the exchange. Whoever completes `/token` must present
|
|
598
|
+
the matching verifier, so an intercepted code alone is useless. PKCE is
|
|
599
|
+
mandatory for every client here — there is no `plain` fallback and never will
|
|
600
|
+
be.
|
|
601
|
+
- **The issuer must match the token's `iss` exactly.** It is compared, not
|
|
602
|
+
normalized. Production is `https://id.zoreal.com`, which is the default;
|
|
603
|
+
override `issuer` only when you have been given a specific non-production
|
|
604
|
+
provider URL to point at. Your backend must reject any token whose `iss` is
|
|
605
|
+
not exactly the issuer it expects.
|
|
606
|
+
|
|
607
|
+
And the rule the whole design rests on: this runs in a browser the threat model
|
|
608
|
+
treats as attacker-controlled, so nothing it resolves is trusted until your
|
|
609
|
+
backend has verified the ID token's signature, `iss`, `aud`, `exp` and `nonce`
|
|
610
|
+
against the JWKS. `unsafeClaims` is named for exactly that reason.
|
|
611
|
+
|
|
277
612
|
## The ZOREAL OAuth2 library family
|
|
278
613
|
|
|
279
614
|
| Repository | Package | Role |
|
|
@@ -289,12 +624,6 @@ origin:
|
|
|
289
624
|
| zoreal-oauth2-java | com.zoreal:oauth2 (Maven Central) | JVM backend |
|
|
290
625
|
| zoreal-oauth2-dotnet | Zoreal.OAuth2 (NuGet) | .NET backend |
|
|
291
626
|
|
|
292
|
-
## Development against a local provider
|
|
293
|
-
|
|
294
|
-
Pass `issuer` to `startLogin` . The issuer value must match the `iss` inside the
|
|
295
|
-
tokens exactly - it is compared, not normalized. Sandbox clients accept any
|
|
296
|
-
localhost origin.
|
|
297
|
-
|
|
298
627
|
## License
|
|
299
628
|
|
|
300
629
|
MIT.
|
package/dist/index.cjs
CHANGED
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/jwt.ts","../src/wire.ts","../src/pairing.ts","../src/pkce.ts","../src/login.ts"],"sourcesContent":["export { startLogin } from './login';\nexport {\n FlowAbandonedError,\n OAuthFlowError,\n exchangeCode,\n isMobileUserAgent,\n pollUntilApproved,\n startPairing,\n} from './pairing';\nexport type { StartPairingParams } from './pairing';\nexport { challengeS256, generateState, generateVerifier } from './pkce';\nexport { unsafeClaims } from './jwt';\nexport {\n DEFAULT_ISSUER,\n POLL_INTERVAL_ENROLLING_MS,\n POLL_INTERVAL_MS,\n SDK_NAME,\n SDK_VERSION,\n WIRE_VERSION,\n} from './wire';\nexport type {\n PairCreated,\n PairImmediate,\n PairStartResponse,\n PairStatusResponse,\n TokenResponse,\n} from './wire';\nexport type {\n AcrValue,\n AuthCodeLoginOptions,\n BrowserDirectLoginOptions,\n ErrorCode,\n LoginHandle,\n NonOAuthError,\n PairingState,\n SelectBy,\n StartLoginOptions,\n ZorealCodeResponse,\n ZorealCredentialResponse,\n} from './types';\n","/**\n * Reads claims OUT of an ID token without verifying it.\n *\n * That is not a shortcut, it is the design: this code runs in a browser the\n * threat model assumes is attacker-controlled, so a signature check here\n * proves nothing to anyone. The token is verified where verification means\n * something: server-side against the JWKS. What this parser feeds is\n * convenience fields (acr on the response object) that the types document as\n * convenience, with the token staying the authority.\n */\n\nexport function unsafeClaims(idToken: string): Record<string, unknown> {\n try {\n const payload = idToken.split('.')[1] ?? '';\n const b64 = payload.replace(/-/g, '+').replace(/_/g, '/');\n const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);\n return JSON.parse(\n new TextDecoder().decode(Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)))\n );\n } catch {\n return {};\n }\n}\n","/**\n * The wire protocol between this package and the ZOREAL OpenID Provider.\n *\n * VERSIONED: a shipped version keeps working until the provider explicitly\n * refuses it, and when it does, the reason is surfaced verbatim. Both the wire\n * version and the package version travel on every pairing request so a refusal\n * can be precise.\n *\n * Endpoints, all relative to the issuer and all CORS-gated on the client's\n * authorized JavaScript origins (the dashboard):\n *\n * POST /pair start a pairing request. Body carries the\n * authorize parameters plus PKCE challenge.\n * Returns { request_id, pair_url, expires_in }\n * or, for prompt=none with a live consented\n * session, { code } immediately.\n * GET /pair/:id/status poll: pending | claimed |\n * approved (with code) | denied | expired |\n * enrolling. Over-polling cancels the request\n * rather than throttling it, so the cadence\n * below is not a suggestion.\n * GET /pair/:id/qr.svg the QR image for the pairing URL, served by\n * the provider so the pairing surface stays\n * changeable at runtime and\n * this package keeps zero dependencies.\n * POST /token the code exchange. Browser-direct mode uses\n * it directly with PKCE and no client secret;\n * auth-code mode leaves it to the RP backend.\n */\n\nexport const WIRE_VERSION = 1;\nexport const SDK_VERSION = '0.1.2';\nexport const SDK_NAME = '@zoreal/oauth2-js';\nexport const DEFAULT_ISSUER = 'https://id.zoreal.com';\n\n/** Pending TTL is short. Poll gently; over-polling cancels the request. */\nexport const POLL_INTERVAL_MS = 2000;\n/** Enrolling extends the window well beyond a normal login; poll slower. */\nexport const POLL_INTERVAL_ENROLLING_MS = 5000;\n\nexport interface PairCreated {\n request_id: string;\n /** https://zoreal.com/qr/<request_id>. The same URL in QR and app link. */\n pair_url: string;\n expires_in: number;\n}\n\nexport interface PairImmediate {\n /** prompt=none resolved silently: consented sector, live session. */\n code: string;\n}\n\nexport type PairStartResponse = PairCreated | PairImmediate;\n\nexport interface PairStatusResponse {\n status: 'pending' | 'claimed' | 'approved' | 'denied' | 'expired' | 'cancelled' | 'enrolling';\n code?: string;\n expires_in?: number;\n enrolment_deadline?: number;\n /** The provider's reason on denial or refusal. Surfaced verbatim, never rewritten. */\n error?: string;\n error_description?: string;\n}\n\nexport interface TokenResponse {\n id_token: string;\n access_token?: string;\n token_type?: string;\n expires_in?: number;\n scope?: string;\n error?: string;\n error_description?: string;\n}\n","/**\n * The pairing channel, client side. wire.ts pins the endpoints.\n *\n * The browser polls; the phone never talks to the browser. Everything here is\n * therefore plain fetch against the issuer, CORS-gated on the client's\n * authorized origins, with the poll cadence fixed: the provider cancels an\n * over-polling request rather than throttling it, so a \"retry\n * faster on error\" strategy here would kill the login it is trying to save.\n */\n\nimport {\n POLL_INTERVAL_ENROLLING_MS,\n POLL_INTERVAL_MS,\n SDK_NAME,\n SDK_VERSION,\n WIRE_VERSION,\n type PairStartResponse,\n type PairStatusResponse,\n type TokenResponse,\n} from './wire';\nimport type { ErrorCode, NonOAuthError, PairingState } from './types';\n\nexport class OAuthFlowError extends Error {\n constructor(\n public error: ErrorCode,\n public description?: string\n ) {\n super(description ?? error);\n }\n}\n\nexport class FlowAbandonedError extends Error {\n constructor(public reason: NonOAuthError) {\n super(reason.description ?? reason.type);\n }\n}\n\nexport interface StartPairingParams {\n client_id: string;\n scope: string;\n state: string;\n nonce: string;\n code_challenge: string;\n redirect_uri?: string;\n acr_values?: string;\n max_age?: number;\n prompt?: string;\n locale?: string;\n}\n\nasync function parseJson(response: Response): Promise<Record<string, unknown>> {\n try {\n return (await response.json()) as Record<string, unknown>;\n } catch {\n return {};\n }\n}\n\nexport async function startPairing(\n issuer: string,\n params: StartPairingParams\n): Promise<PairStartResponse> {\n const response = await fetch(`${issuer}/pair`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n ...params,\n code_challenge_method: 'S256',\n wire_version: WIRE_VERSION,\n sdk: `${SDK_NAME}/${SDK_VERSION}`,\n }),\n });\n\n const body = await parseJson(response);\n if (!response.ok) {\n // The provider's words, verbatim. A refused package version arrives here,\n // and rewriting its reason would hide the only signal telling an integrator\n // to upgrade.\n throw new OAuthFlowError(\n (body.error as ErrorCode) ?? 'server_error',\n (body.error_description as string) ?? `The provider refused the request (${response.status})`\n );\n }\n return body as unknown as PairStartResponse;\n}\n\nconst sleep = (ms: number, signal?: AbortSignal) =>\n new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(new DOMException('aborted', 'AbortError'));\n return;\n }\n const t = setTimeout(resolve, ms);\n signal?.addEventListener('abort', () => {\n clearTimeout(t);\n reject(new DOMException('aborted', 'AbortError'));\n });\n });\n\n/**\n * Polls until the request resolves. Returns the authorization code.\n * Throws FlowAbandonedError for the human outcomes (denied, expired,\n * enrolment abandoned) and OAuthFlowError for protocol ones.\n */\nexport async function pollUntilApproved(\n issuer: string,\n requestId: string,\n onState?: (state: PairingState) => void,\n signal?: AbortSignal\n): Promise<string> {\n for (;;) {\n const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {\n signal,\n });\n const body = (await parseJson(response)) as unknown as PairStatusResponse;\n\n if (!response.ok) {\n throw new OAuthFlowError(\n (body.error as ErrorCode) ?? 'server_error',\n body.error_description ?? `Pairing status failed (${response.status})`\n );\n }\n\n onState?.({\n status: body.status,\n expiresIn: body.expires_in,\n enrolmentDeadline: body.enrolment_deadline,\n });\n\n switch (body.status) {\n case 'approved':\n if (!body.code) {\n throw new OAuthFlowError('server_error', 'approved with no authorization code');\n }\n return body.code;\n case 'denied':\n throw new FlowAbandonedError({ type: 'request_denied', description: body.error_description });\n case 'expired':\n throw new FlowAbandonedError({ type: 'request_expired', description: body.error_description });\n case 'cancelled':\n // The provider cancels an over-polled or abandoned request outright\n // (its pairing rows have a real cancelled state); a poll that treats\n // it as unknown spins on a dead request forever.\n throw new FlowAbandonedError({\n type: 'request_expired',\n description: body.error_description ?? 'the provider cancelled the pairing request',\n });\n case 'enrolling':\n await sleep(POLL_INTERVAL_ENROLLING_MS, signal);\n break;\n default:\n await sleep(POLL_INTERVAL_MS, signal);\n }\n }\n}\n\n/**\n * The code exchange, browser-direct mode only: a public client, PKCE and no\n * secret. What comes back can only ever be the pseudonymous tier, by\n * construction rather than by rule: personal data lives at /userinfo behind an\n * access token this mode is never issued, because personal-data scopes are\n * refused for public clients at the pairing step.\n */\nexport async function exchangeCode(\n issuer: string,\n input: { code: string; code_verifier: string; client_id: string }\n): Promise<TokenResponse> {\n const response = await fetch(`${issuer}/token`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'authorization_code',\n code: input.code,\n code_verifier: input.code_verifier,\n client_id: input.client_id,\n }),\n });\n\n const body = (await parseJson(response)) as unknown as TokenResponse;\n if (!response.ok || body.error) {\n throw new OAuthFlowError(\n (body.error as ErrorCode) ?? 'server_error',\n body.error_description ?? `Token exchange failed (${response.status})`\n );\n }\n return body;\n}\n\n/** A mobile user agent gets the app link, not a QR of its own screen. */\nexport function isMobileUserAgent(): boolean {\n if (typeof navigator === 'undefined') return false;\n return /android|iphone|ipad|ipod/i.test(navigator.userAgent);\n}\n","/**\n * PKCE, S256 only: mandatory for every client, confidential ones included.\n * There is no plain fallback and there must never be one; a provider seeing\n * method=plain is seeing a bug or an attack.\n */\n\nconst VERIFIER_BYTES = 32; // 43 base64url chars, the RFC 7636 minimum length\n\nconst base64url = (bytes: Uint8Array): string =>\n btoa(String.fromCharCode(...bytes))\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '');\n\nexport function generateVerifier(): string {\n const bytes = new Uint8Array(VERIFIER_BYTES);\n crypto.getRandomValues(bytes);\n return base64url(bytes);\n}\n\nexport async function challengeS256(verifier: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));\n return base64url(new Uint8Array(digest));\n}\n\nexport function generateState(): string {\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n return base64url(bytes);\n}\n","/**\n * The one flow, as an imperative handle. This is the same state machine the\n * React SDK's hook runs, without the React: start a pairing, surface it for\n * rendering through onState, poll, and finish per mode. Browser-direct\n * exchanges the code here (public client, PKCE, no secret) and hands over an\n * ID token; auth-code hands the code and the PKCE verifier to the caller,\n * whose backend does the exchange with its client authentication.\n *\n * A framework wrapper owns exactly two things: calling startLogin on the\n * user's gesture, and rendering what onState carries. Everything else -\n * PKCE, state, nonce, cadence, cancellation - lives here.\n */\n\nimport { unsafeClaims } from './jwt';\nimport {\n FlowAbandonedError,\n OAuthFlowError,\n exchangeCode,\n isMobileUserAgent,\n pollUntilApproved,\n startPairing,\n} from './pairing';\nimport { challengeS256, generateState, generateVerifier } from './pkce';\nimport { DEFAULT_ISSUER } from './wire';\nimport type {\n AcrValue,\n AuthCodeLoginOptions,\n BrowserDirectLoginOptions,\n LoginHandle,\n SelectBy,\n ZorealCodeResponse,\n ZorealCredentialResponse,\n} from './types';\n\nexport function startLogin(\n options: BrowserDirectLoginOptions\n): LoginHandle<ZorealCredentialResponse>;\nexport function startLogin(options: AuthCodeLoginOptions): LoginHandle<ZorealCodeResponse>;\nexport function startLogin(\n options: BrowserDirectLoginOptions | AuthCodeLoginOptions\n): LoginHandle<ZorealCredentialResponse> | LoginHandle<ZorealCodeResponse> {\n if ('ux_mode' in options && options.ux_mode === 'redirect') {\n // The popup shape only: the code and PKCE verifier resolve the promise\n // and go from there to your backend over TLS. A redirect would have to\n // carry the verifier in a URL, which is a credential in every access\n // log on the path. Refused loudly rather than implemented badly.\n throw new Error(\n \"@zoreal/oauth2-js: ux_mode 'redirect' is not supported. Use the default \" +\n \"'popup' shape and post the code and code_verifier from the resolved \" +\n 'promise to your backend.'\n );\n }\n\n const flow = options.flow ?? 'browser-direct';\n const issuer = options.issuer ?? DEFAULT_ISSUER;\n const controller = new AbortController();\n\n const surface: {\n requestId?: string;\n pairUrl?: string;\n qrUrl?: string;\n appLink?: boolean;\n } = {};\n\n const cancel = () => controller.abort();\n\n const run = async (): Promise<ZorealCredentialResponse | ZorealCodeResponse> => {\n const verifier = generateVerifier();\n const state = generateState();\n const nonce = generateState();\n\n try {\n const started = await startPairing(issuer, {\n client_id: options.clientId,\n scope: options.scope ?? 'openid',\n state,\n nonce,\n code_challenge: await challengeS256(verifier),\n redirect_uri: flow === 'auth-code' ? (options as AuthCodeLoginOptions).redirect_uri : undefined,\n acr_values: Array.isArray(options.acr_values)\n ? options.acr_values.join(' ')\n : options.acr_values,\n max_age: options.max_age,\n prompt: options.prompt,\n locale: options.locale,\n });\n\n let code: string;\n let selectBy: SelectBy = 'device';\n\n if ('code' in started) {\n // prompt=none resolved silently: consented sector, live session.\n code = started.code;\n selectBy = 'session';\n } else {\n const useAppLink =\n options.display === 'link' || (options.display !== 'qr' && isMobileUserAgent());\n selectBy = useAppLink ? 'app_link' : 'qr';\n\n surface.requestId = started.request_id;\n surface.pairUrl = started.pair_url;\n surface.qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;\n surface.appLink = useAppLink;\n\n // Everything a caller-rendered pairing UI needs, on every state it\n // sees: the QR flow cannot complete unless SOMETHING renders pairUrl,\n // and in this package that something is always the caller.\n const stateSurface = {\n pairUrl: surface.pairUrl,\n qrUrl: surface.qrUrl,\n appLink: useAppLink,\n cancel,\n };\n\n // The initial state, immediately: the first poll response is one\n // round-trip away, and a UI that waits for it opens visibly empty.\n options.onState?.({ status: 'pending', expiresIn: started.expires_in, ...stateSurface });\n\n if (useAppLink && typeof window !== 'undefined') {\n // The universal link, in the same tab: the app claims it, and with\n // no app installed the same URL is the real pairing page, which can\n // enrol. A popup here would be blocked more often than it would\n // help.\n window.location.assign(started.pair_url);\n }\n\n code = await pollUntilApproved(\n issuer,\n started.request_id,\n (s) => options.onState?.({ ...s, ...stateSurface }),\n controller.signal\n );\n }\n\n if (flow === 'auth-code') {\n const response: ZorealCodeResponse = {\n code,\n scope: options.scope ?? 'openid',\n app_state: options.app_state,\n code_verifier: verifier,\n nonce,\n };\n return response;\n }\n\n const tokens = await exchangeCode(issuer, {\n code,\n code_verifier: verifier,\n client_id: options.clientId,\n });\n const claims = unsafeClaims(tokens.id_token);\n const response: ZorealCredentialResponse = {\n credential: tokens.id_token,\n clientId: options.clientId,\n select_by: selectBy,\n acr: (claims.acr as AcrValue) ?? 'zoreal.device',\n };\n return response;\n } catch (e) {\n // The taxonomy the promise rejects with, and nothing else:\n // OAuthFlowError the provider refused; reason verbatim\n // FlowAbandonedError a human outcome, or a failure that never\n // reached the provider (network, unknown)\n // AbortError the caller's own cancel()\n if (e instanceof DOMException && e.name === 'AbortError') throw e;\n if (e instanceof OAuthFlowError || e instanceof FlowAbandonedError) throw e;\n throw new FlowAbandonedError({\n type: 'unknown',\n description: e instanceof Error ? e.message : String(e),\n });\n }\n };\n\n const promise = run();\n // A caller driving everything from onState and cancel() may never attach a\n // rejection handler; this no-op one keeps a cancelled login from surfacing\n // as an unhandled rejection. The caller's own catch still sees the error.\n promise.catch(() => {});\n\n return {\n promise: promise as Promise<ZorealCredentialResponse> & Promise<ZorealCodeResponse>,\n cancel,\n get requestId() {\n return surface.requestId;\n },\n get pairUrl() {\n return surface.pairUrl;\n },\n get qrUrl() {\n return surface.qrUrl;\n },\n get appLink() {\n return surface.appLink;\n },\n };\n}\n"],"mappings":"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","names":["response"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/jwt.ts","../src/wire.ts","../src/pairing.ts","../src/pkce.ts","../src/login.ts"],"sourcesContent":["export { startLogin } from './login';\nexport {\n FlowAbandonedError,\n OAuthFlowError,\n exchangeCode,\n isMobileUserAgent,\n pollUntilApproved,\n startPairing,\n} from './pairing';\nexport type { StartPairingParams } from './pairing';\nexport { challengeS256, generateState, generateVerifier } from './pkce';\nexport { unsafeClaims } from './jwt';\nexport {\n DEFAULT_ISSUER,\n POLL_INTERVAL_ENROLLING_MS,\n POLL_INTERVAL_MS,\n SDK_NAME,\n SDK_VERSION,\n WIRE_VERSION,\n} from './wire';\nexport type {\n PairCreated,\n PairImmediate,\n PairStartResponse,\n PairStatusResponse,\n TokenResponse,\n} from './wire';\nexport type {\n AcrValue,\n AuthCodeLoginOptions,\n BrowserDirectLoginOptions,\n ErrorCode,\n LoginHandle,\n NonOAuthError,\n PairingState,\n SelectBy,\n StartLoginOptions,\n ZorealCodeResponse,\n ZorealCredentialResponse,\n} from './types';\n","/**\n * Reads claims OUT of an ID token without verifying it.\n *\n * That is not a shortcut, it is the design: this code runs in a browser the\n * threat model assumes is attacker-controlled, so a signature check here\n * proves nothing to anyone. The token is verified where verification means\n * something: server-side against the JWKS. What this parser feeds is\n * convenience fields (acr on the response object) that the types document as\n * convenience, with the token staying the authority.\n */\n\nexport function unsafeClaims(idToken: string): Record<string, unknown> {\n try {\n const payload = idToken.split('.')[1] ?? '';\n const b64 = payload.replace(/-/g, '+').replace(/_/g, '/');\n const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);\n return JSON.parse(\n new TextDecoder().decode(Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)))\n );\n } catch {\n return {};\n }\n}\n","/**\n * The wire protocol between this package and the ZOREAL OpenID Provider.\n *\n * VERSIONED: a shipped version keeps working until the provider explicitly\n * refuses it, and when it does, the reason is surfaced verbatim. Both the wire\n * version and the package version travel on every pairing request so a refusal\n * can be precise.\n *\n * Endpoints, all relative to the issuer and all CORS-gated on the client's\n * authorized JavaScript origins (the dashboard):\n *\n * POST /pair start a pairing request. Body carries the\n * authorize parameters plus PKCE challenge.\n * Returns { request_id, pair_url, expires_in }\n * or, for prompt=none with a live consented\n * session, { code } immediately.\n * GET /pair/:id/status poll: pending | claimed |\n * approved (with code) | denied | expired |\n * enrolling. Over-polling cancels the request\n * rather than throttling it, so the cadence\n * below is not a suggestion.\n * GET /pair/:id/qr.svg the QR image for the pairing URL, served by\n * the provider so the pairing surface stays\n * changeable at runtime and\n * this package keeps zero dependencies.\n * POST /token the code exchange. Browser-direct mode uses\n * it directly with PKCE and no client secret;\n * auth-code mode leaves it to the RP backend.\n */\n\nexport const WIRE_VERSION = 1;\nexport const SDK_VERSION = '0.1.4';\nexport const SDK_NAME = '@zoreal/oauth2-js';\nexport const DEFAULT_ISSUER = 'https://id.zoreal.com';\n\n/** Pending TTL is short. Poll gently; over-polling cancels the request. */\nexport const POLL_INTERVAL_MS = 2000;\n/** Enrolling extends the window well beyond a normal login; poll slower. */\nexport const POLL_INTERVAL_ENROLLING_MS = 5000;\n\nexport interface PairCreated {\n request_id: string;\n /** https://zoreal.com/qr/<request_id>. The same URL in QR and app link. */\n pair_url: string;\n expires_in: number;\n}\n\nexport interface PairImmediate {\n /** prompt=none resolved silently: consented sector, live session. */\n code: string;\n}\n\nexport type PairStartResponse = PairCreated | PairImmediate;\n\nexport interface PairStatusResponse {\n status: 'pending' | 'claimed' | 'approved' | 'denied' | 'expired' | 'cancelled' | 'enrolling';\n code?: string;\n expires_in?: number;\n enrolment_deadline?: number;\n /** The provider's reason on denial or refusal. Surfaced verbatim, never rewritten. */\n error?: string;\n error_description?: string;\n}\n\nexport interface TokenResponse {\n id_token: string;\n access_token?: string;\n token_type?: string;\n expires_in?: number;\n scope?: string;\n error?: string;\n error_description?: string;\n}\n","/**\n * The pairing channel, client side. wire.ts pins the endpoints.\n *\n * The browser polls; the phone never talks to the browser. Everything here is\n * therefore plain fetch against the issuer, CORS-gated on the client's\n * authorized origins, with the poll cadence fixed: the provider cancels an\n * over-polling request rather than throttling it, so a \"retry\n * faster on error\" strategy here would kill the login it is trying to save.\n */\n\nimport {\n POLL_INTERVAL_ENROLLING_MS,\n POLL_INTERVAL_MS,\n SDK_NAME,\n SDK_VERSION,\n WIRE_VERSION,\n type PairStartResponse,\n type PairStatusResponse,\n type TokenResponse,\n} from './wire';\nimport type { ErrorCode, NonOAuthError, PairingState } from './types';\n\nexport class OAuthFlowError extends Error {\n constructor(\n public error: ErrorCode,\n public description?: string\n ) {\n super(description ?? error);\n }\n}\n\nexport class FlowAbandonedError extends Error {\n constructor(public reason: NonOAuthError) {\n super(reason.description ?? reason.type);\n }\n}\n\nexport interface StartPairingParams {\n client_id: string;\n scope: string;\n state: string;\n nonce: string;\n code_challenge: string;\n redirect_uri?: string;\n acr_values?: string;\n max_age?: number;\n prompt?: string;\n locale?: string;\n}\n\nasync function parseJson(response: Response): Promise<Record<string, unknown>> {\n try {\n return (await response.json()) as Record<string, unknown>;\n } catch {\n return {};\n }\n}\n\nexport async function startPairing(\n issuer: string,\n params: StartPairingParams\n): Promise<PairStartResponse> {\n const response = await fetch(`${issuer}/pair`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n ...params,\n code_challenge_method: 'S256',\n wire_version: WIRE_VERSION,\n sdk: `${SDK_NAME}/${SDK_VERSION}`,\n }),\n });\n\n const body = await parseJson(response);\n if (!response.ok) {\n // The provider's words, verbatim. A refused package version arrives here,\n // and rewriting its reason would hide the only signal telling an integrator\n // to upgrade.\n throw new OAuthFlowError(\n (body.error as ErrorCode) ?? 'server_error',\n (body.error_description as string) ?? `The provider refused the request (${response.status})`\n );\n }\n return body as unknown as PairStartResponse;\n}\n\nconst sleep = (ms: number, signal?: AbortSignal) =>\n new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(new DOMException('aborted', 'AbortError'));\n return;\n }\n const t = setTimeout(resolve, ms);\n signal?.addEventListener('abort', () => {\n clearTimeout(t);\n reject(new DOMException('aborted', 'AbortError'));\n });\n });\n\n/**\n * Polls until the request resolves. Returns the authorization code.\n * Throws FlowAbandonedError for the human outcomes (denied, expired,\n * enrolment abandoned) and OAuthFlowError for protocol ones.\n */\nexport async function pollUntilApproved(\n issuer: string,\n requestId: string,\n onState?: (state: PairingState) => void,\n signal?: AbortSignal\n): Promise<string> {\n for (;;) {\n const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {\n signal,\n });\n const body = (await parseJson(response)) as unknown as PairStatusResponse;\n\n if (!response.ok) {\n throw new OAuthFlowError(\n (body.error as ErrorCode) ?? 'server_error',\n body.error_description ?? `Pairing status failed (${response.status})`\n );\n }\n\n onState?.({\n status: body.status,\n expiresIn: body.expires_in,\n enrolmentDeadline: body.enrolment_deadline,\n });\n\n switch (body.status) {\n case 'approved':\n if (!body.code) {\n throw new OAuthFlowError('server_error', 'approved with no authorization code');\n }\n return body.code;\n case 'denied':\n throw new FlowAbandonedError({ type: 'request_denied', description: body.error_description });\n case 'expired':\n throw new FlowAbandonedError({ type: 'request_expired', description: body.error_description });\n case 'cancelled':\n // The provider cancels an over-polled or abandoned request outright\n // (its pairing rows have a real cancelled state); a poll that treats\n // it as unknown spins on a dead request forever.\n throw new FlowAbandonedError({\n type: 'request_expired',\n description: body.error_description ?? 'the provider cancelled the pairing request',\n });\n case 'enrolling':\n await sleep(POLL_INTERVAL_ENROLLING_MS, signal);\n break;\n default:\n await sleep(POLL_INTERVAL_MS, signal);\n }\n }\n}\n\n/**\n * The code exchange, browser-direct mode only: a public client, PKCE and no\n * secret. What comes back can only ever be the pseudonymous tier, by\n * construction rather than by rule: personal data lives at /userinfo behind an\n * access token this mode is never issued, because personal-data scopes are\n * refused for public clients at the pairing step.\n */\nexport async function exchangeCode(\n issuer: string,\n input: { code: string; code_verifier: string; client_id: string }\n): Promise<TokenResponse> {\n const response = await fetch(`${issuer}/token`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'authorization_code',\n code: input.code,\n code_verifier: input.code_verifier,\n client_id: input.client_id,\n }),\n });\n\n const body = (await parseJson(response)) as unknown as TokenResponse;\n if (!response.ok || body.error) {\n throw new OAuthFlowError(\n (body.error as ErrorCode) ?? 'server_error',\n body.error_description ?? `Token exchange failed (${response.status})`\n );\n }\n return body;\n}\n\n/** A mobile user agent gets the app link, not a QR of its own screen. */\nexport function isMobileUserAgent(): boolean {\n if (typeof navigator === 'undefined') return false;\n return /android|iphone|ipad|ipod/i.test(navigator.userAgent);\n}\n","/**\n * PKCE, S256 only: mandatory for every client, confidential ones included.\n * There is no plain fallback and there must never be one; a provider seeing\n * method=plain is seeing a bug or an attack.\n */\n\nconst VERIFIER_BYTES = 32; // 43 base64url chars, the RFC 7636 minimum length\n\nconst base64url = (bytes: Uint8Array): string =>\n btoa(String.fromCharCode(...bytes))\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '');\n\nexport function generateVerifier(): string {\n const bytes = new Uint8Array(VERIFIER_BYTES);\n crypto.getRandomValues(bytes);\n return base64url(bytes);\n}\n\nexport async function challengeS256(verifier: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));\n return base64url(new Uint8Array(digest));\n}\n\nexport function generateState(): string {\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n return base64url(bytes);\n}\n","/**\n * The one flow, as an imperative handle. This is the same state machine the\n * React SDK's hook runs, without the React: start a pairing, surface it for\n * rendering through onState, poll, and finish per mode. Browser-direct\n * exchanges the code here (public client, PKCE, no secret) and hands over an\n * ID token; auth-code hands the code and the PKCE verifier to the caller,\n * whose backend does the exchange with its client authentication.\n *\n * A framework wrapper owns exactly two things: calling startLogin on the\n * user's gesture, and rendering what onState carries. Everything else -\n * PKCE, state, nonce, cadence, cancellation - lives here.\n */\n\nimport { unsafeClaims } from './jwt';\nimport {\n FlowAbandonedError,\n OAuthFlowError,\n exchangeCode,\n isMobileUserAgent,\n pollUntilApproved,\n startPairing,\n} from './pairing';\nimport { challengeS256, generateState, generateVerifier } from './pkce';\nimport { DEFAULT_ISSUER } from './wire';\nimport type {\n AcrValue,\n AuthCodeLoginOptions,\n BrowserDirectLoginOptions,\n LoginHandle,\n SelectBy,\n ZorealCodeResponse,\n ZorealCredentialResponse,\n} from './types';\n\nexport function startLogin(\n options: BrowserDirectLoginOptions\n): LoginHandle<ZorealCredentialResponse>;\nexport function startLogin(options: AuthCodeLoginOptions): LoginHandle<ZorealCodeResponse>;\nexport function startLogin(\n options: BrowserDirectLoginOptions | AuthCodeLoginOptions\n): LoginHandle<ZorealCredentialResponse> | LoginHandle<ZorealCodeResponse> {\n if ('ux_mode' in options && options.ux_mode === 'redirect') {\n // The popup shape only: the code and PKCE verifier resolve the promise\n // and go from there to your backend over TLS. A redirect would have to\n // carry the verifier in a URL, which is a credential in every access\n // log on the path. Refused loudly rather than implemented badly.\n throw new Error(\n \"@zoreal/oauth2-js: ux_mode 'redirect' is not supported. Use the default \" +\n \"'popup' shape and post the code and code_verifier from the resolved \" +\n 'promise to your backend.'\n );\n }\n\n const flow = options.flow ?? 'browser-direct';\n const issuer = options.issuer ?? DEFAULT_ISSUER;\n const controller = new AbortController();\n\n const surface: {\n requestId?: string;\n pairUrl?: string;\n qrUrl?: string;\n appLink?: boolean;\n } = {};\n\n const cancel = () => controller.abort();\n\n const run = async (): Promise<ZorealCredentialResponse | ZorealCodeResponse> => {\n const verifier = generateVerifier();\n const state = generateState();\n const nonce = generateState();\n\n try {\n const started = await startPairing(issuer, {\n client_id: options.clientId,\n scope: options.scope ?? 'openid',\n state,\n nonce,\n code_challenge: await challengeS256(verifier),\n redirect_uri: flow === 'auth-code' ? (options as AuthCodeLoginOptions).redirect_uri : undefined,\n acr_values: Array.isArray(options.acr_values)\n ? options.acr_values.join(' ')\n : options.acr_values,\n max_age: options.max_age,\n prompt: options.prompt,\n locale: options.locale,\n });\n\n let code: string;\n let selectBy: SelectBy = 'device';\n\n if ('code' in started) {\n // prompt=none resolved silently: consented sector, live session.\n code = started.code;\n selectBy = 'session';\n } else {\n const useAppLink =\n options.display === 'link' || (options.display !== 'qr' && isMobileUserAgent());\n selectBy = useAppLink ? 'app_link' : 'qr';\n\n surface.requestId = started.request_id;\n surface.pairUrl = started.pair_url;\n surface.qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;\n surface.appLink = useAppLink;\n\n // Everything a caller-rendered pairing UI needs, on every state it\n // sees: the QR flow cannot complete unless SOMETHING renders pairUrl,\n // and in this package that something is always the caller.\n const stateSurface = {\n pairUrl: surface.pairUrl,\n qrUrl: surface.qrUrl,\n appLink: useAppLink,\n cancel,\n };\n\n // The initial state, immediately: the first poll response is one\n // round-trip away, and a UI that waits for it opens visibly empty.\n options.onState?.({ status: 'pending', expiresIn: started.expires_in, ...stateSurface });\n\n if (useAppLink && typeof window !== 'undefined') {\n // The universal link, in the same tab: the app claims it, and with\n // no app installed the same URL is the real pairing page, which can\n // enrol. A popup here would be blocked more often than it would\n // help.\n window.location.assign(started.pair_url);\n }\n\n code = await pollUntilApproved(\n issuer,\n started.request_id,\n (s) => options.onState?.({ ...s, ...stateSurface }),\n controller.signal\n );\n }\n\n if (flow === 'auth-code') {\n const response: ZorealCodeResponse = {\n code,\n scope: options.scope ?? 'openid',\n app_state: options.app_state,\n code_verifier: verifier,\n nonce,\n };\n return response;\n }\n\n const tokens = await exchangeCode(issuer, {\n code,\n code_verifier: verifier,\n client_id: options.clientId,\n });\n const claims = unsafeClaims(tokens.id_token);\n const response: ZorealCredentialResponse = {\n credential: tokens.id_token,\n clientId: options.clientId,\n select_by: selectBy,\n acr: (claims.acr as AcrValue) ?? 'zoreal.device',\n };\n return response;\n } catch (e) {\n // The taxonomy the promise rejects with, and nothing else:\n // OAuthFlowError the provider refused; reason verbatim\n // FlowAbandonedError a human outcome, or a failure that never\n // reached the provider (network, unknown)\n // AbortError the caller's own cancel()\n if (e instanceof DOMException && e.name === 'AbortError') throw e;\n if (e instanceof OAuthFlowError || e instanceof FlowAbandonedError) throw e;\n throw new FlowAbandonedError({\n type: 'unknown',\n description: e instanceof Error ? e.message : String(e),\n });\n }\n };\n\n const promise = run();\n // A caller driving everything from onState and cancel() may never attach a\n // rejection handler; this no-op one keeps a cancelled login from surfacing\n // as an unhandled rejection. The caller's own catch still sees the error.\n promise.catch(() => {});\n\n return {\n promise: promise as Promise<ZorealCredentialResponse> & Promise<ZorealCodeResponse>,\n cancel,\n get requestId() {\n return surface.requestId;\n },\n get pairUrl() {\n return surface.pairUrl;\n },\n get qrUrl() {\n return surface.qrUrl;\n },\n get appLink() {\n return surface.appLink;\n },\n };\n}\n"],"mappings":"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","names":["response"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -15,7 +15,12 @@ type NonOAuthError = {
|
|
|
15
15
|
};
|
|
16
16
|
/** How the holder reached this login. */
|
|
17
17
|
type SelectBy = 'qr' | 'app_link' | 'device' | 'session';
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* How the login was actually authenticated. Describes what happened, never
|
|
20
|
+
* what was requested: 'zoreal.live' means a fresh liveness capture was passed
|
|
21
|
+
* for this login. As acr_values it is a request; only the signed acr claim in
|
|
22
|
+
* the ID token confirms it, so verify that claim on your backend.
|
|
23
|
+
*/
|
|
19
24
|
type AcrValue = 'zoreal.live' | 'zoreal.device' | 'zoreal.session';
|
|
20
25
|
interface PairingState {
|
|
21
26
|
status: 'pending' | 'claimed' | 'approved' | 'denied' | 'expired' | 'cancelled' | 'enrolling';
|
|
@@ -72,7 +77,12 @@ interface StartLoginOptions {
|
|
|
72
77
|
issuer?: string;
|
|
73
78
|
/** Defaults to 'openid'. Scopes that return personal data require flow: 'auth-code'. */
|
|
74
79
|
scope?: string;
|
|
75
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Ask for a specific assurance. 'zoreal.live' requires a fresh liveness
|
|
82
|
+
* capture in the ZOREAL ID app before the login can complete. Advisory:
|
|
83
|
+
* your backend must verify the signed acr claim in the ID token. Omit to
|
|
84
|
+
* accept the default, zoreal.device.
|
|
85
|
+
*/
|
|
76
86
|
acr_values?: AcrValue | AcrValue[];
|
|
77
87
|
/** Seconds. Forces re-authentication when auth_time is older. */
|
|
78
88
|
max_age?: number;
|
|
@@ -168,7 +178,7 @@ declare function startLogin(options: AuthCodeLoginOptions): LoginHandle<ZorealCo
|
|
|
168
178
|
* auth-code mode leaves it to the RP backend.
|
|
169
179
|
*/
|
|
170
180
|
declare const WIRE_VERSION = 1;
|
|
171
|
-
declare const SDK_VERSION = "0.1.
|
|
181
|
+
declare const SDK_VERSION = "0.1.4";
|
|
172
182
|
declare const SDK_NAME = "@zoreal/oauth2-js";
|
|
173
183
|
declare const DEFAULT_ISSUER = "https://id.zoreal.com";
|
|
174
184
|
/** Pending TTL is short. Poll gently; over-polling cancels the request. */
|
package/dist/index.d.ts
CHANGED
|
@@ -15,7 +15,12 @@ type NonOAuthError = {
|
|
|
15
15
|
};
|
|
16
16
|
/** How the holder reached this login. */
|
|
17
17
|
type SelectBy = 'qr' | 'app_link' | 'device' | 'session';
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* How the login was actually authenticated. Describes what happened, never
|
|
20
|
+
* what was requested: 'zoreal.live' means a fresh liveness capture was passed
|
|
21
|
+
* for this login. As acr_values it is a request; only the signed acr claim in
|
|
22
|
+
* the ID token confirms it, so verify that claim on your backend.
|
|
23
|
+
*/
|
|
19
24
|
type AcrValue = 'zoreal.live' | 'zoreal.device' | 'zoreal.session';
|
|
20
25
|
interface PairingState {
|
|
21
26
|
status: 'pending' | 'claimed' | 'approved' | 'denied' | 'expired' | 'cancelled' | 'enrolling';
|
|
@@ -72,7 +77,12 @@ interface StartLoginOptions {
|
|
|
72
77
|
issuer?: string;
|
|
73
78
|
/** Defaults to 'openid'. Scopes that return personal data require flow: 'auth-code'. */
|
|
74
79
|
scope?: string;
|
|
75
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Ask for a specific assurance. 'zoreal.live' requires a fresh liveness
|
|
82
|
+
* capture in the ZOREAL ID app before the login can complete. Advisory:
|
|
83
|
+
* your backend must verify the signed acr claim in the ID token. Omit to
|
|
84
|
+
* accept the default, zoreal.device.
|
|
85
|
+
*/
|
|
76
86
|
acr_values?: AcrValue | AcrValue[];
|
|
77
87
|
/** Seconds. Forces re-authentication when auth_time is older. */
|
|
78
88
|
max_age?: number;
|
|
@@ -168,7 +178,7 @@ declare function startLogin(options: AuthCodeLoginOptions): LoginHandle<ZorealCo
|
|
|
168
178
|
* auth-code mode leaves it to the RP backend.
|
|
169
179
|
*/
|
|
170
180
|
declare const WIRE_VERSION = 1;
|
|
171
|
-
declare const SDK_VERSION = "0.1.
|
|
181
|
+
declare const SDK_VERSION = "0.1.4";
|
|
172
182
|
declare const SDK_NAME = "@zoreal/oauth2-js";
|
|
173
183
|
declare const DEFAULT_ISSUER = "https://id.zoreal.com";
|
|
174
184
|
/** Pending TTL is short. Poll gently; over-polling cancels the request. */
|
package/dist/index.js
CHANGED
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/jwt.ts","../src/wire.ts","../src/pairing.ts","../src/pkce.ts","../src/login.ts"],"sourcesContent":["/**\n * Reads claims OUT of an ID token without verifying it.\n *\n * That is not a shortcut, it is the design: this code runs in a browser the\n * threat model assumes is attacker-controlled, so a signature check here\n * proves nothing to anyone. The token is verified where verification means\n * something: server-side against the JWKS. What this parser feeds is\n * convenience fields (acr on the response object) that the types document as\n * convenience, with the token staying the authority.\n */\n\nexport function unsafeClaims(idToken: string): Record<string, unknown> {\n try {\n const payload = idToken.split('.')[1] ?? '';\n const b64 = payload.replace(/-/g, '+').replace(/_/g, '/');\n const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);\n return JSON.parse(\n new TextDecoder().decode(Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)))\n );\n } catch {\n return {};\n }\n}\n","/**\n * The wire protocol between this package and the ZOREAL OpenID Provider.\n *\n * VERSIONED: a shipped version keeps working until the provider explicitly\n * refuses it, and when it does, the reason is surfaced verbatim. Both the wire\n * version and the package version travel on every pairing request so a refusal\n * can be precise.\n *\n * Endpoints, all relative to the issuer and all CORS-gated on the client's\n * authorized JavaScript origins (the dashboard):\n *\n * POST /pair start a pairing request. Body carries the\n * authorize parameters plus PKCE challenge.\n * Returns { request_id, pair_url, expires_in }\n * or, for prompt=none with a live consented\n * session, { code } immediately.\n * GET /pair/:id/status poll: pending | claimed |\n * approved (with code) | denied | expired |\n * enrolling. Over-polling cancels the request\n * rather than throttling it, so the cadence\n * below is not a suggestion.\n * GET /pair/:id/qr.svg the QR image for the pairing URL, served by\n * the provider so the pairing surface stays\n * changeable at runtime and\n * this package keeps zero dependencies.\n * POST /token the code exchange. Browser-direct mode uses\n * it directly with PKCE and no client secret;\n * auth-code mode leaves it to the RP backend.\n */\n\nexport const WIRE_VERSION = 1;\nexport const SDK_VERSION = '0.1.2';\nexport const SDK_NAME = '@zoreal/oauth2-js';\nexport const DEFAULT_ISSUER = 'https://id.zoreal.com';\n\n/** Pending TTL is short. Poll gently; over-polling cancels the request. */\nexport const POLL_INTERVAL_MS = 2000;\n/** Enrolling extends the window well beyond a normal login; poll slower. */\nexport const POLL_INTERVAL_ENROLLING_MS = 5000;\n\nexport interface PairCreated {\n request_id: string;\n /** https://zoreal.com/qr/<request_id>. The same URL in QR and app link. */\n pair_url: string;\n expires_in: number;\n}\n\nexport interface PairImmediate {\n /** prompt=none resolved silently: consented sector, live session. */\n code: string;\n}\n\nexport type PairStartResponse = PairCreated | PairImmediate;\n\nexport interface PairStatusResponse {\n status: 'pending' | 'claimed' | 'approved' | 'denied' | 'expired' | 'cancelled' | 'enrolling';\n code?: string;\n expires_in?: number;\n enrolment_deadline?: number;\n /** The provider's reason on denial or refusal. Surfaced verbatim, never rewritten. */\n error?: string;\n error_description?: string;\n}\n\nexport interface TokenResponse {\n id_token: string;\n access_token?: string;\n token_type?: string;\n expires_in?: number;\n scope?: string;\n error?: string;\n error_description?: string;\n}\n","/**\n * The pairing channel, client side. wire.ts pins the endpoints.\n *\n * The browser polls; the phone never talks to the browser. Everything here is\n * therefore plain fetch against the issuer, CORS-gated on the client's\n * authorized origins, with the poll cadence fixed: the provider cancels an\n * over-polling request rather than throttling it, so a \"retry\n * faster on error\" strategy here would kill the login it is trying to save.\n */\n\nimport {\n POLL_INTERVAL_ENROLLING_MS,\n POLL_INTERVAL_MS,\n SDK_NAME,\n SDK_VERSION,\n WIRE_VERSION,\n type PairStartResponse,\n type PairStatusResponse,\n type TokenResponse,\n} from './wire';\nimport type { ErrorCode, NonOAuthError, PairingState } from './types';\n\nexport class OAuthFlowError extends Error {\n constructor(\n public error: ErrorCode,\n public description?: string\n ) {\n super(description ?? error);\n }\n}\n\nexport class FlowAbandonedError extends Error {\n constructor(public reason: NonOAuthError) {\n super(reason.description ?? reason.type);\n }\n}\n\nexport interface StartPairingParams {\n client_id: string;\n scope: string;\n state: string;\n nonce: string;\n code_challenge: string;\n redirect_uri?: string;\n acr_values?: string;\n max_age?: number;\n prompt?: string;\n locale?: string;\n}\n\nasync function parseJson(response: Response): Promise<Record<string, unknown>> {\n try {\n return (await response.json()) as Record<string, unknown>;\n } catch {\n return {};\n }\n}\n\nexport async function startPairing(\n issuer: string,\n params: StartPairingParams\n): Promise<PairStartResponse> {\n const response = await fetch(`${issuer}/pair`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n ...params,\n code_challenge_method: 'S256',\n wire_version: WIRE_VERSION,\n sdk: `${SDK_NAME}/${SDK_VERSION}`,\n }),\n });\n\n const body = await parseJson(response);\n if (!response.ok) {\n // The provider's words, verbatim. A refused package version arrives here,\n // and rewriting its reason would hide the only signal telling an integrator\n // to upgrade.\n throw new OAuthFlowError(\n (body.error as ErrorCode) ?? 'server_error',\n (body.error_description as string) ?? `The provider refused the request (${response.status})`\n );\n }\n return body as unknown as PairStartResponse;\n}\n\nconst sleep = (ms: number, signal?: AbortSignal) =>\n new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(new DOMException('aborted', 'AbortError'));\n return;\n }\n const t = setTimeout(resolve, ms);\n signal?.addEventListener('abort', () => {\n clearTimeout(t);\n reject(new DOMException('aborted', 'AbortError'));\n });\n });\n\n/**\n * Polls until the request resolves. Returns the authorization code.\n * Throws FlowAbandonedError for the human outcomes (denied, expired,\n * enrolment abandoned) and OAuthFlowError for protocol ones.\n */\nexport async function pollUntilApproved(\n issuer: string,\n requestId: string,\n onState?: (state: PairingState) => void,\n signal?: AbortSignal\n): Promise<string> {\n for (;;) {\n const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {\n signal,\n });\n const body = (await parseJson(response)) as unknown as PairStatusResponse;\n\n if (!response.ok) {\n throw new OAuthFlowError(\n (body.error as ErrorCode) ?? 'server_error',\n body.error_description ?? `Pairing status failed (${response.status})`\n );\n }\n\n onState?.({\n status: body.status,\n expiresIn: body.expires_in,\n enrolmentDeadline: body.enrolment_deadline,\n });\n\n switch (body.status) {\n case 'approved':\n if (!body.code) {\n throw new OAuthFlowError('server_error', 'approved with no authorization code');\n }\n return body.code;\n case 'denied':\n throw new FlowAbandonedError({ type: 'request_denied', description: body.error_description });\n case 'expired':\n throw new FlowAbandonedError({ type: 'request_expired', description: body.error_description });\n case 'cancelled':\n // The provider cancels an over-polled or abandoned request outright\n // (its pairing rows have a real cancelled state); a poll that treats\n // it as unknown spins on a dead request forever.\n throw new FlowAbandonedError({\n type: 'request_expired',\n description: body.error_description ?? 'the provider cancelled the pairing request',\n });\n case 'enrolling':\n await sleep(POLL_INTERVAL_ENROLLING_MS, signal);\n break;\n default:\n await sleep(POLL_INTERVAL_MS, signal);\n }\n }\n}\n\n/**\n * The code exchange, browser-direct mode only: a public client, PKCE and no\n * secret. What comes back can only ever be the pseudonymous tier, by\n * construction rather than by rule: personal data lives at /userinfo behind an\n * access token this mode is never issued, because personal-data scopes are\n * refused for public clients at the pairing step.\n */\nexport async function exchangeCode(\n issuer: string,\n input: { code: string; code_verifier: string; client_id: string }\n): Promise<TokenResponse> {\n const response = await fetch(`${issuer}/token`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'authorization_code',\n code: input.code,\n code_verifier: input.code_verifier,\n client_id: input.client_id,\n }),\n });\n\n const body = (await parseJson(response)) as unknown as TokenResponse;\n if (!response.ok || body.error) {\n throw new OAuthFlowError(\n (body.error as ErrorCode) ?? 'server_error',\n body.error_description ?? `Token exchange failed (${response.status})`\n );\n }\n return body;\n}\n\n/** A mobile user agent gets the app link, not a QR of its own screen. */\nexport function isMobileUserAgent(): boolean {\n if (typeof navigator === 'undefined') return false;\n return /android|iphone|ipad|ipod/i.test(navigator.userAgent);\n}\n","/**\n * PKCE, S256 only: mandatory for every client, confidential ones included.\n * There is no plain fallback and there must never be one; a provider seeing\n * method=plain is seeing a bug or an attack.\n */\n\nconst VERIFIER_BYTES = 32; // 43 base64url chars, the RFC 7636 minimum length\n\nconst base64url = (bytes: Uint8Array): string =>\n btoa(String.fromCharCode(...bytes))\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '');\n\nexport function generateVerifier(): string {\n const bytes = new Uint8Array(VERIFIER_BYTES);\n crypto.getRandomValues(bytes);\n return base64url(bytes);\n}\n\nexport async function challengeS256(verifier: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));\n return base64url(new Uint8Array(digest));\n}\n\nexport function generateState(): string {\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n return base64url(bytes);\n}\n","/**\n * The one flow, as an imperative handle. This is the same state machine the\n * React SDK's hook runs, without the React: start a pairing, surface it for\n * rendering through onState, poll, and finish per mode. Browser-direct\n * exchanges the code here (public client, PKCE, no secret) and hands over an\n * ID token; auth-code hands the code and the PKCE verifier to the caller,\n * whose backend does the exchange with its client authentication.\n *\n * A framework wrapper owns exactly two things: calling startLogin on the\n * user's gesture, and rendering what onState carries. Everything else -\n * PKCE, state, nonce, cadence, cancellation - lives here.\n */\n\nimport { unsafeClaims } from './jwt';\nimport {\n FlowAbandonedError,\n OAuthFlowError,\n exchangeCode,\n isMobileUserAgent,\n pollUntilApproved,\n startPairing,\n} from './pairing';\nimport { challengeS256, generateState, generateVerifier } from './pkce';\nimport { DEFAULT_ISSUER } from './wire';\nimport type {\n AcrValue,\n AuthCodeLoginOptions,\n BrowserDirectLoginOptions,\n LoginHandle,\n SelectBy,\n ZorealCodeResponse,\n ZorealCredentialResponse,\n} from './types';\n\nexport function startLogin(\n options: BrowserDirectLoginOptions\n): LoginHandle<ZorealCredentialResponse>;\nexport function startLogin(options: AuthCodeLoginOptions): LoginHandle<ZorealCodeResponse>;\nexport function startLogin(\n options: BrowserDirectLoginOptions | AuthCodeLoginOptions\n): LoginHandle<ZorealCredentialResponse> | LoginHandle<ZorealCodeResponse> {\n if ('ux_mode' in options && options.ux_mode === 'redirect') {\n // The popup shape only: the code and PKCE verifier resolve the promise\n // and go from there to your backend over TLS. A redirect would have to\n // carry the verifier in a URL, which is a credential in every access\n // log on the path. Refused loudly rather than implemented badly.\n throw new Error(\n \"@zoreal/oauth2-js: ux_mode 'redirect' is not supported. Use the default \" +\n \"'popup' shape and post the code and code_verifier from the resolved \" +\n 'promise to your backend.'\n );\n }\n\n const flow = options.flow ?? 'browser-direct';\n const issuer = options.issuer ?? DEFAULT_ISSUER;\n const controller = new AbortController();\n\n const surface: {\n requestId?: string;\n pairUrl?: string;\n qrUrl?: string;\n appLink?: boolean;\n } = {};\n\n const cancel = () => controller.abort();\n\n const run = async (): Promise<ZorealCredentialResponse | ZorealCodeResponse> => {\n const verifier = generateVerifier();\n const state = generateState();\n const nonce = generateState();\n\n try {\n const started = await startPairing(issuer, {\n client_id: options.clientId,\n scope: options.scope ?? 'openid',\n state,\n nonce,\n code_challenge: await challengeS256(verifier),\n redirect_uri: flow === 'auth-code' ? (options as AuthCodeLoginOptions).redirect_uri : undefined,\n acr_values: Array.isArray(options.acr_values)\n ? options.acr_values.join(' ')\n : options.acr_values,\n max_age: options.max_age,\n prompt: options.prompt,\n locale: options.locale,\n });\n\n let code: string;\n let selectBy: SelectBy = 'device';\n\n if ('code' in started) {\n // prompt=none resolved silently: consented sector, live session.\n code = started.code;\n selectBy = 'session';\n } else {\n const useAppLink =\n options.display === 'link' || (options.display !== 'qr' && isMobileUserAgent());\n selectBy = useAppLink ? 'app_link' : 'qr';\n\n surface.requestId = started.request_id;\n surface.pairUrl = started.pair_url;\n surface.qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;\n surface.appLink = useAppLink;\n\n // Everything a caller-rendered pairing UI needs, on every state it\n // sees: the QR flow cannot complete unless SOMETHING renders pairUrl,\n // and in this package that something is always the caller.\n const stateSurface = {\n pairUrl: surface.pairUrl,\n qrUrl: surface.qrUrl,\n appLink: useAppLink,\n cancel,\n };\n\n // The initial state, immediately: the first poll response is one\n // round-trip away, and a UI that waits for it opens visibly empty.\n options.onState?.({ status: 'pending', expiresIn: started.expires_in, ...stateSurface });\n\n if (useAppLink && typeof window !== 'undefined') {\n // The universal link, in the same tab: the app claims it, and with\n // no app installed the same URL is the real pairing page, which can\n // enrol. A popup here would be blocked more often than it would\n // help.\n window.location.assign(started.pair_url);\n }\n\n code = await pollUntilApproved(\n issuer,\n started.request_id,\n (s) => options.onState?.({ ...s, ...stateSurface }),\n controller.signal\n );\n }\n\n if (flow === 'auth-code') {\n const response: ZorealCodeResponse = {\n code,\n scope: options.scope ?? 'openid',\n app_state: options.app_state,\n code_verifier: verifier,\n nonce,\n };\n return response;\n }\n\n const tokens = await exchangeCode(issuer, {\n code,\n code_verifier: verifier,\n client_id: options.clientId,\n });\n const claims = unsafeClaims(tokens.id_token);\n const response: ZorealCredentialResponse = {\n credential: tokens.id_token,\n clientId: options.clientId,\n select_by: selectBy,\n acr: (claims.acr as AcrValue) ?? 'zoreal.device',\n };\n return response;\n } catch (e) {\n // The taxonomy the promise rejects with, and nothing else:\n // OAuthFlowError the provider refused; reason verbatim\n // FlowAbandonedError a human outcome, or a failure that never\n // reached the provider (network, unknown)\n // AbortError the caller's own cancel()\n if (e instanceof DOMException && e.name === 'AbortError') throw e;\n if (e instanceof OAuthFlowError || e instanceof FlowAbandonedError) throw e;\n throw new FlowAbandonedError({\n type: 'unknown',\n description: e instanceof Error ? e.message : String(e),\n });\n }\n };\n\n const promise = run();\n // A caller driving everything from onState and cancel() may never attach a\n // rejection handler; this no-op one keeps a cancelled login from surfacing\n // as an unhandled rejection. The caller's own catch still sees the error.\n promise.catch(() => {});\n\n return {\n promise: promise as Promise<ZorealCredentialResponse> & Promise<ZorealCodeResponse>,\n cancel,\n get requestId() {\n return surface.requestId;\n },\n get pairUrl() {\n return surface.pairUrl;\n },\n get qrUrl() {\n return surface.qrUrl;\n },\n get appLink() {\n return surface.appLink;\n },\n };\n}\n"],"mappings":"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","names":["response"]}
|
|
1
|
+
{"version":3,"sources":["../src/jwt.ts","../src/wire.ts","../src/pairing.ts","../src/pkce.ts","../src/login.ts"],"sourcesContent":["/**\n * Reads claims OUT of an ID token without verifying it.\n *\n * That is not a shortcut, it is the design: this code runs in a browser the\n * threat model assumes is attacker-controlled, so a signature check here\n * proves nothing to anyone. The token is verified where verification means\n * something: server-side against the JWKS. What this parser feeds is\n * convenience fields (acr on the response object) that the types document as\n * convenience, with the token staying the authority.\n */\n\nexport function unsafeClaims(idToken: string): Record<string, unknown> {\n try {\n const payload = idToken.split('.')[1] ?? '';\n const b64 = payload.replace(/-/g, '+').replace(/_/g, '/');\n const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);\n return JSON.parse(\n new TextDecoder().decode(Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)))\n );\n } catch {\n return {};\n }\n}\n","/**\n * The wire protocol between this package and the ZOREAL OpenID Provider.\n *\n * VERSIONED: a shipped version keeps working until the provider explicitly\n * refuses it, and when it does, the reason is surfaced verbatim. Both the wire\n * version and the package version travel on every pairing request so a refusal\n * can be precise.\n *\n * Endpoints, all relative to the issuer and all CORS-gated on the client's\n * authorized JavaScript origins (the dashboard):\n *\n * POST /pair start a pairing request. Body carries the\n * authorize parameters plus PKCE challenge.\n * Returns { request_id, pair_url, expires_in }\n * or, for prompt=none with a live consented\n * session, { code } immediately.\n * GET /pair/:id/status poll: pending | claimed |\n * approved (with code) | denied | expired |\n * enrolling. Over-polling cancels the request\n * rather than throttling it, so the cadence\n * below is not a suggestion.\n * GET /pair/:id/qr.svg the QR image for the pairing URL, served by\n * the provider so the pairing surface stays\n * changeable at runtime and\n * this package keeps zero dependencies.\n * POST /token the code exchange. Browser-direct mode uses\n * it directly with PKCE and no client secret;\n * auth-code mode leaves it to the RP backend.\n */\n\nexport const WIRE_VERSION = 1;\nexport const SDK_VERSION = '0.1.4';\nexport const SDK_NAME = '@zoreal/oauth2-js';\nexport const DEFAULT_ISSUER = 'https://id.zoreal.com';\n\n/** Pending TTL is short. Poll gently; over-polling cancels the request. */\nexport const POLL_INTERVAL_MS = 2000;\n/** Enrolling extends the window well beyond a normal login; poll slower. */\nexport const POLL_INTERVAL_ENROLLING_MS = 5000;\n\nexport interface PairCreated {\n request_id: string;\n /** https://zoreal.com/qr/<request_id>. The same URL in QR and app link. */\n pair_url: string;\n expires_in: number;\n}\n\nexport interface PairImmediate {\n /** prompt=none resolved silently: consented sector, live session. */\n code: string;\n}\n\nexport type PairStartResponse = PairCreated | PairImmediate;\n\nexport interface PairStatusResponse {\n status: 'pending' | 'claimed' | 'approved' | 'denied' | 'expired' | 'cancelled' | 'enrolling';\n code?: string;\n expires_in?: number;\n enrolment_deadline?: number;\n /** The provider's reason on denial or refusal. Surfaced verbatim, never rewritten. */\n error?: string;\n error_description?: string;\n}\n\nexport interface TokenResponse {\n id_token: string;\n access_token?: string;\n token_type?: string;\n expires_in?: number;\n scope?: string;\n error?: string;\n error_description?: string;\n}\n","/**\n * The pairing channel, client side. wire.ts pins the endpoints.\n *\n * The browser polls; the phone never talks to the browser. Everything here is\n * therefore plain fetch against the issuer, CORS-gated on the client's\n * authorized origins, with the poll cadence fixed: the provider cancels an\n * over-polling request rather than throttling it, so a \"retry\n * faster on error\" strategy here would kill the login it is trying to save.\n */\n\nimport {\n POLL_INTERVAL_ENROLLING_MS,\n POLL_INTERVAL_MS,\n SDK_NAME,\n SDK_VERSION,\n WIRE_VERSION,\n type PairStartResponse,\n type PairStatusResponse,\n type TokenResponse,\n} from './wire';\nimport type { ErrorCode, NonOAuthError, PairingState } from './types';\n\nexport class OAuthFlowError extends Error {\n constructor(\n public error: ErrorCode,\n public description?: string\n ) {\n super(description ?? error);\n }\n}\n\nexport class FlowAbandonedError extends Error {\n constructor(public reason: NonOAuthError) {\n super(reason.description ?? reason.type);\n }\n}\n\nexport interface StartPairingParams {\n client_id: string;\n scope: string;\n state: string;\n nonce: string;\n code_challenge: string;\n redirect_uri?: string;\n acr_values?: string;\n max_age?: number;\n prompt?: string;\n locale?: string;\n}\n\nasync function parseJson(response: Response): Promise<Record<string, unknown>> {\n try {\n return (await response.json()) as Record<string, unknown>;\n } catch {\n return {};\n }\n}\n\nexport async function startPairing(\n issuer: string,\n params: StartPairingParams\n): Promise<PairStartResponse> {\n const response = await fetch(`${issuer}/pair`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n ...params,\n code_challenge_method: 'S256',\n wire_version: WIRE_VERSION,\n sdk: `${SDK_NAME}/${SDK_VERSION}`,\n }),\n });\n\n const body = await parseJson(response);\n if (!response.ok) {\n // The provider's words, verbatim. A refused package version arrives here,\n // and rewriting its reason would hide the only signal telling an integrator\n // to upgrade.\n throw new OAuthFlowError(\n (body.error as ErrorCode) ?? 'server_error',\n (body.error_description as string) ?? `The provider refused the request (${response.status})`\n );\n }\n return body as unknown as PairStartResponse;\n}\n\nconst sleep = (ms: number, signal?: AbortSignal) =>\n new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(new DOMException('aborted', 'AbortError'));\n return;\n }\n const t = setTimeout(resolve, ms);\n signal?.addEventListener('abort', () => {\n clearTimeout(t);\n reject(new DOMException('aborted', 'AbortError'));\n });\n });\n\n/**\n * Polls until the request resolves. Returns the authorization code.\n * Throws FlowAbandonedError for the human outcomes (denied, expired,\n * enrolment abandoned) and OAuthFlowError for protocol ones.\n */\nexport async function pollUntilApproved(\n issuer: string,\n requestId: string,\n onState?: (state: PairingState) => void,\n signal?: AbortSignal\n): Promise<string> {\n for (;;) {\n const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {\n signal,\n });\n const body = (await parseJson(response)) as unknown as PairStatusResponse;\n\n if (!response.ok) {\n throw new OAuthFlowError(\n (body.error as ErrorCode) ?? 'server_error',\n body.error_description ?? `Pairing status failed (${response.status})`\n );\n }\n\n onState?.({\n status: body.status,\n expiresIn: body.expires_in,\n enrolmentDeadline: body.enrolment_deadline,\n });\n\n switch (body.status) {\n case 'approved':\n if (!body.code) {\n throw new OAuthFlowError('server_error', 'approved with no authorization code');\n }\n return body.code;\n case 'denied':\n throw new FlowAbandonedError({ type: 'request_denied', description: body.error_description });\n case 'expired':\n throw new FlowAbandonedError({ type: 'request_expired', description: body.error_description });\n case 'cancelled':\n // The provider cancels an over-polled or abandoned request outright\n // (its pairing rows have a real cancelled state); a poll that treats\n // it as unknown spins on a dead request forever.\n throw new FlowAbandonedError({\n type: 'request_expired',\n description: body.error_description ?? 'the provider cancelled the pairing request',\n });\n case 'enrolling':\n await sleep(POLL_INTERVAL_ENROLLING_MS, signal);\n break;\n default:\n await sleep(POLL_INTERVAL_MS, signal);\n }\n }\n}\n\n/**\n * The code exchange, browser-direct mode only: a public client, PKCE and no\n * secret. What comes back can only ever be the pseudonymous tier, by\n * construction rather than by rule: personal data lives at /userinfo behind an\n * access token this mode is never issued, because personal-data scopes are\n * refused for public clients at the pairing step.\n */\nexport async function exchangeCode(\n issuer: string,\n input: { code: string; code_verifier: string; client_id: string }\n): Promise<TokenResponse> {\n const response = await fetch(`${issuer}/token`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'authorization_code',\n code: input.code,\n code_verifier: input.code_verifier,\n client_id: input.client_id,\n }),\n });\n\n const body = (await parseJson(response)) as unknown as TokenResponse;\n if (!response.ok || body.error) {\n throw new OAuthFlowError(\n (body.error as ErrorCode) ?? 'server_error',\n body.error_description ?? `Token exchange failed (${response.status})`\n );\n }\n return body;\n}\n\n/** A mobile user agent gets the app link, not a QR of its own screen. */\nexport function isMobileUserAgent(): boolean {\n if (typeof navigator === 'undefined') return false;\n return /android|iphone|ipad|ipod/i.test(navigator.userAgent);\n}\n","/**\n * PKCE, S256 only: mandatory for every client, confidential ones included.\n * There is no plain fallback and there must never be one; a provider seeing\n * method=plain is seeing a bug or an attack.\n */\n\nconst VERIFIER_BYTES = 32; // 43 base64url chars, the RFC 7636 minimum length\n\nconst base64url = (bytes: Uint8Array): string =>\n btoa(String.fromCharCode(...bytes))\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '');\n\nexport function generateVerifier(): string {\n const bytes = new Uint8Array(VERIFIER_BYTES);\n crypto.getRandomValues(bytes);\n return base64url(bytes);\n}\n\nexport async function challengeS256(verifier: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));\n return base64url(new Uint8Array(digest));\n}\n\nexport function generateState(): string {\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n return base64url(bytes);\n}\n","/**\n * The one flow, as an imperative handle. This is the same state machine the\n * React SDK's hook runs, without the React: start a pairing, surface it for\n * rendering through onState, poll, and finish per mode. Browser-direct\n * exchanges the code here (public client, PKCE, no secret) and hands over an\n * ID token; auth-code hands the code and the PKCE verifier to the caller,\n * whose backend does the exchange with its client authentication.\n *\n * A framework wrapper owns exactly two things: calling startLogin on the\n * user's gesture, and rendering what onState carries. Everything else -\n * PKCE, state, nonce, cadence, cancellation - lives here.\n */\n\nimport { unsafeClaims } from './jwt';\nimport {\n FlowAbandonedError,\n OAuthFlowError,\n exchangeCode,\n isMobileUserAgent,\n pollUntilApproved,\n startPairing,\n} from './pairing';\nimport { challengeS256, generateState, generateVerifier } from './pkce';\nimport { DEFAULT_ISSUER } from './wire';\nimport type {\n AcrValue,\n AuthCodeLoginOptions,\n BrowserDirectLoginOptions,\n LoginHandle,\n SelectBy,\n ZorealCodeResponse,\n ZorealCredentialResponse,\n} from './types';\n\nexport function startLogin(\n options: BrowserDirectLoginOptions\n): LoginHandle<ZorealCredentialResponse>;\nexport function startLogin(options: AuthCodeLoginOptions): LoginHandle<ZorealCodeResponse>;\nexport function startLogin(\n options: BrowserDirectLoginOptions | AuthCodeLoginOptions\n): LoginHandle<ZorealCredentialResponse> | LoginHandle<ZorealCodeResponse> {\n if ('ux_mode' in options && options.ux_mode === 'redirect') {\n // The popup shape only: the code and PKCE verifier resolve the promise\n // and go from there to your backend over TLS. A redirect would have to\n // carry the verifier in a URL, which is a credential in every access\n // log on the path. Refused loudly rather than implemented badly.\n throw new Error(\n \"@zoreal/oauth2-js: ux_mode 'redirect' is not supported. Use the default \" +\n \"'popup' shape and post the code and code_verifier from the resolved \" +\n 'promise to your backend.'\n );\n }\n\n const flow = options.flow ?? 'browser-direct';\n const issuer = options.issuer ?? DEFAULT_ISSUER;\n const controller = new AbortController();\n\n const surface: {\n requestId?: string;\n pairUrl?: string;\n qrUrl?: string;\n appLink?: boolean;\n } = {};\n\n const cancel = () => controller.abort();\n\n const run = async (): Promise<ZorealCredentialResponse | ZorealCodeResponse> => {\n const verifier = generateVerifier();\n const state = generateState();\n const nonce = generateState();\n\n try {\n const started = await startPairing(issuer, {\n client_id: options.clientId,\n scope: options.scope ?? 'openid',\n state,\n nonce,\n code_challenge: await challengeS256(verifier),\n redirect_uri: flow === 'auth-code' ? (options as AuthCodeLoginOptions).redirect_uri : undefined,\n acr_values: Array.isArray(options.acr_values)\n ? options.acr_values.join(' ')\n : options.acr_values,\n max_age: options.max_age,\n prompt: options.prompt,\n locale: options.locale,\n });\n\n let code: string;\n let selectBy: SelectBy = 'device';\n\n if ('code' in started) {\n // prompt=none resolved silently: consented sector, live session.\n code = started.code;\n selectBy = 'session';\n } else {\n const useAppLink =\n options.display === 'link' || (options.display !== 'qr' && isMobileUserAgent());\n selectBy = useAppLink ? 'app_link' : 'qr';\n\n surface.requestId = started.request_id;\n surface.pairUrl = started.pair_url;\n surface.qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;\n surface.appLink = useAppLink;\n\n // Everything a caller-rendered pairing UI needs, on every state it\n // sees: the QR flow cannot complete unless SOMETHING renders pairUrl,\n // and in this package that something is always the caller.\n const stateSurface = {\n pairUrl: surface.pairUrl,\n qrUrl: surface.qrUrl,\n appLink: useAppLink,\n cancel,\n };\n\n // The initial state, immediately: the first poll response is one\n // round-trip away, and a UI that waits for it opens visibly empty.\n options.onState?.({ status: 'pending', expiresIn: started.expires_in, ...stateSurface });\n\n if (useAppLink && typeof window !== 'undefined') {\n // The universal link, in the same tab: the app claims it, and with\n // no app installed the same URL is the real pairing page, which can\n // enrol. A popup here would be blocked more often than it would\n // help.\n window.location.assign(started.pair_url);\n }\n\n code = await pollUntilApproved(\n issuer,\n started.request_id,\n (s) => options.onState?.({ ...s, ...stateSurface }),\n controller.signal\n );\n }\n\n if (flow === 'auth-code') {\n const response: ZorealCodeResponse = {\n code,\n scope: options.scope ?? 'openid',\n app_state: options.app_state,\n code_verifier: verifier,\n nonce,\n };\n return response;\n }\n\n const tokens = await exchangeCode(issuer, {\n code,\n code_verifier: verifier,\n client_id: options.clientId,\n });\n const claims = unsafeClaims(tokens.id_token);\n const response: ZorealCredentialResponse = {\n credential: tokens.id_token,\n clientId: options.clientId,\n select_by: selectBy,\n acr: (claims.acr as AcrValue) ?? 'zoreal.device',\n };\n return response;\n } catch (e) {\n // The taxonomy the promise rejects with, and nothing else:\n // OAuthFlowError the provider refused; reason verbatim\n // FlowAbandonedError a human outcome, or a failure that never\n // reached the provider (network, unknown)\n // AbortError the caller's own cancel()\n if (e instanceof DOMException && e.name === 'AbortError') throw e;\n if (e instanceof OAuthFlowError || e instanceof FlowAbandonedError) throw e;\n throw new FlowAbandonedError({\n type: 'unknown',\n description: e instanceof Error ? e.message : String(e),\n });\n }\n };\n\n const promise = run();\n // A caller driving everything from onState and cancel() may never attach a\n // rejection handler; this no-op one keeps a cancelled login from surfacing\n // as an unhandled rejection. The caller's own catch still sees the error.\n promise.catch(() => {});\n\n return {\n promise: promise as Promise<ZorealCredentialResponse> & Promise<ZorealCodeResponse>,\n cancel,\n get requestId() {\n return surface.requestId;\n },\n get pairUrl() {\n return surface.pairUrl;\n },\n get qrUrl() {\n return surface.qrUrl;\n },\n get appLink() {\n return surface.appLink;\n },\n };\n}\n"],"mappings":"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","names":["response"]}
|
package/package.json
CHANGED