@zoreal/oauth2-js 0.1.3 → 0.1.5

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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # @zoreal/oauth2-js
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@zoreal/oauth2-js)](https://www.npmjs.com/package/@zoreal/oauth2-js) [![npm downloads](https://img.shields.io/npm/dm/@zoreal/oauth2-js)](https://www.npmjs.com/package/@zoreal/oauth2-js) [![types](https://img.shields.io/npm/types/@zoreal/oauth2-js)](https://www.npmjs.com/package/@zoreal/oauth2-js) [![CI](https://img.shields.io/github/actions/workflow/status/Bynn-Intelligence/zoreal-oauth2-js/ci.yml?branch=main&label=CI)](https://github.com/Bynn-Intelligence/zoreal-oauth2-js/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
4
+
3
5
  Login with ZOREAL for the browser, framework-free: a ZOREAL Verified
4
6
  Proof-of-Human behind every sign-in.
5
7
 
@@ -15,13 +17,6 @@ React package is what that wrapper looks like when it is finished.
15
17
  your wrapper or plain JS the UI: render what onState carries
16
18
  ```
17
19
 
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
20
  ## Install
26
21
 
27
22
  ```sh
@@ -31,6 +26,47 @@ npm install @zoreal/oauth2-js
31
26
  Zero runtime dependencies. ESM and CJS. Browser APIs only (`fetch`,
32
27
  `crypto.subtle`); any evergreen browser has everything it needs.
33
28
 
29
+ ## Getting your credentials
30
+
31
+ `clientId` is the only credential this package needs, and it comes from a ZOREAL
32
+ **asset**.
33
+
34
+ 1. Create an account at **https://zoreal.com** and open **Assets**.
35
+ 2. **Create an asset** — a *website* (a domain you own) or an *app bundle* (a
36
+ reverse-DNS bundle id). An asset is the thing users log in to; its token is
37
+ your `clientId` and it looks like `ast_...`.
38
+ 3. On the asset, open the **OAuth2** tab and set:
39
+ - the **JavaScript origins** this page is served from and the **redirect
40
+ URIs** your app uses — requests from anything not registered are rejected,
41
+ which is the core control,
42
+ - the **scopes** the client may request (see the catalogue below); a request
43
+ for a scope not on the list is refused at the pairing step,
44
+ - **client authentication** — for the auth-code flow, generate a **client
45
+ secret** or register a **JWKS** on the asset. That credential lives on your
46
+ backend and never comes here. Browser-direct is a public client: PKCE
47
+ alone, no secret.
48
+ 4. A website asset must **verify its domain** (a DNS or meta-tag proof, shown in
49
+ the dashboard) before it can request personal-data scopes or sign users in;
50
+ the verified domain is what your users' `sub` is pairwise against.
51
+
52
+ `clientId` is public by design — it ships in your frontend, and this package
53
+ takes nothing else. No client secret has a home in the browser (see *No secret
54
+ has a home here*, below).
55
+
56
+ ### There is no test-identity sandbox — and that is deliberate
57
+
58
+ ZOREAL **never issues fake or sandbox humans**: a pool of test identities would
59
+ be a fraud vector against the exact thing the product proves. So you always
60
+ authenticate **real** ZOREAL IDs.
61
+
62
+ To develop and test, **create a free ZOREAL ID for yourself** (enrol in the
63
+ ZOREAL ID app) and sign in with it. Mark your asset's environment **sandbox** in
64
+ the dashboard while building — a sandbox asset may register `http://localhost`
65
+ origins and redirect URIs that a production asset may not — and flip it to
66
+ production when you ship. The identities are real either way; only the allowed
67
+ origins differ. There is no mock provider and no hosted test issuer to point at:
68
+ the issuer is `https://id.zoreal.com` in every environment.
69
+
34
70
  ## Two flows: pick by whether you need the user's details
35
71
 
36
72
  - **You have a backend and want the user's email or name** (most apps): use
@@ -142,6 +178,34 @@ Auth-code:
142
178
  URL, which is a credential in every access log on the path. `startLogin`
143
179
  throws rather than doing that.
144
180
 
181
+ ## Scopes and claims
182
+
183
+ Scopes are the `scope` string you pass to `startLogin` (always starting with
184
+ `openid`), consented to by the holder, and pre-authorized on your asset. What
185
+ each grants and where it is delivered:
186
+
187
+ | Scope | Claims | Delivered in | Tier | Requires |
188
+ |---|---|---|---|---|
189
+ | `openid` | `sub`, `iss`, `aud`, `exp`, `iat`, `nonce`, `auth_time`, `acr`, `amr`, and the assurance block | ID token | A | any client |
190
+ | `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 |
191
+ | `zoreal.nationality` | `nationality` (ISO 3166-1 alpha-3) | ID token | A | any client |
192
+ | `email` | `email`, `email_verified` | `/userinfo` | B | confidential client + verified domain |
193
+ | `profile.name` | `name`, `given_name`, `family_name` | `/userinfo` | B | confidential client + verified domain |
194
+ | `profile.birthdate` | `birthdate` (full ISO 8601 date) | `/userinfo` | B | confidential client + verified domain |
195
+ | `profile.document` | `document_type`, `document_number`, `issuing_country`, `document_expires_on` | `/userinfo` | B | confidential client + verified domain |
196
+ | `profile.portrait` | `portrait` (the chip's facial image; GDPR Article 9 data) | `/userinfo` | C | confidential client + verified domain — *registrable but not served yet* |
197
+
198
+ - **Tier A** rides in the ID token and is available to every client, so the
199
+ browser-direct flow can use it with no backend at all. **Tier B and C** are
200
+ personal data, served only from `/userinfo` to a confidential client on a
201
+ domain you have verified, and never placed in a browser token — which is why
202
+ any scope beyond Tier A needs `flow: 'auth-code'` and your backend. A public
203
+ client that asks for one is refused at the pairing step with `invalid_scope`.
204
+ - **Age thresholds are a fixed set** — 13, 16, 18, 21, 65 — that you register on
205
+ the asset. The `age_over_*` claim for a threshold you did not register is
206
+ simply absent (no claim was minted), which a backend reads as `null`/`nil`
207
+ rather than `false`.
208
+
145
209
  ## Assurance levels — `acr` and requiring a liveness check
146
210
 
147
211
  ### What `acr` is
@@ -214,6 +278,28 @@ basis, verification month, chip-liveness, trust tier, key protection) describes
214
278
  the *identity behind it*. One is about now; the other about who they are. A
215
279
  high-value flow wants both.
216
280
 
281
+ ## The assurance block
282
+
283
+ The ID token carries a `zoreal` claim — the **assurance block** — describing the
284
+ strength of the *identity* behind this login, distinct from `acr`, which grades
285
+ the *login event*. In browser-direct mode you can read it for display with
286
+ `unsafeClaims(credential).zoreal` (convenience only — the token is the authority
287
+ once your backend has verified it); in the auth-code flow your backend reads it
288
+ from the verified token. Its keys and their value sets:
289
+
290
+ | Key | Values | Meaning |
291
+ |---|---|---|
292
+ | `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 |
293
+ | `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 |
294
+ | `chip_liveness_proven` | `true` \| `false` | Whether the passport chip's active-authentication challenge was proven (a genuine chip, not a clone) |
295
+ | `trust_tier` | `high` \| `standard` | `high` when `chip_liveness_proven`, else `standard` |
296
+ | `key_protection` | `secure_enclave` \| `strongbox` \| `tee` \| `software` | How the holder's device key is protected. `software` means no hardware attestation |
297
+
298
+ A high-value flow usually pairs `acr_values: 'zoreal.live'` (fresh presence)
299
+ with a check on the assurance block (identity strength) — e.g. requiring
300
+ `uniqueness === 'personal_number'` and `trust_tier === 'high'`. Both checks are
301
+ enforced where enforcement counts: on your backend, against the verified token.
302
+
217
303
  ## API
218
304
 
219
305
  | Export | What it does |
@@ -237,6 +323,157 @@ All types are exported: `PairingState`, `ZorealCredentialResponse`,
237
323
  `AuthCodeLoginOptions`, `LoginHandle`, `ErrorCode`, `NonOAuthError`,
238
324
  `SelectBy`, `AcrValue`, and the wire shapes.
239
325
 
326
+ ## Error reference
327
+
328
+ ### At `/token`
329
+
330
+ The code exchange can fail with these OAuth codes. In **browser-direct** mode
331
+ this package makes the `/token` call for you (`exchangeCode`), and a failure
332
+ arrives as an `OAuthFlowError` whose `error` is one of these. In **auth-code**
333
+ mode the `/token` call is your backend's, and it sees the same codes there.
334
+
335
+ | `error` | Cause | Retryable? |
336
+ |---|---|---|
337
+ | `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 |
338
+ | `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 |
339
+ | `unsupported_grant_type` | Something other than `authorization_code` reached `/token` | No. A bug |
340
+
341
+ ### Before the exchange — surfaced in the browser
342
+
343
+ These come back from the pairing step, before any code exists, and are what your
344
+ UI handles directly:
345
+
346
+ | Where | Code / reason | This package | Meaning |
347
+ |---|---|---|---|
348
+ | `/pair` | `invalid_scope` | `OAuthFlowError` | A scope not on the asset's allowed list, or a Tier B scope from a public client |
349
+ | `/pair` | `invalid_request` | `OAuthFlowError` | Missing PKCE/nonce, an unverified sector, an unregistered `redirect_uri`, or an unknown `acr_values` |
350
+ | `/pair` | `login_required` | `OAuthFlowError` | `prompt: 'none'` with no silent session to resume — the expected quiet outcome, not a failure |
351
+ | pairing | `request_denied` | `FlowAbandonedError` | The holder declined in their ZOREAL ID app — **not an error to alarm on**; offer to try again |
352
+ | pairing | `request_expired` | `FlowAbandonedError` | The pairing window elapsed, or a required liveness the device could not meet — offer to try again |
353
+
354
+ ### This package's error classes
355
+
356
+ - **`OAuthFlowError`** — the provider refused. `error` is the OAuth code (an
357
+ `ErrorCode`), and `description` is the provider's own reason string. Render
358
+ `description` verbatim; it is the only signal that tells an integrator what to
359
+ fix (a refused package version arrives this way too).
360
+ - **`FlowAbandonedError`** — a *human* outcome, or a failure that never reached
361
+ the provider. `reason.type` is `request_denied`, `request_expired`,
362
+ `enrolment_abandoned`, or `unknown`, and `reason.description` carries the
363
+ provider's words when there are any. `request_denied` and `request_expired`
364
+ are the everyday cancel/timeout paths — treat them as "offer to try again",
365
+ not as faults to log at error level.
366
+ - **`AbortError`** — a `DOMException` named `AbortError`, thrown when *you* call
367
+ `handle.cancel()` (or the `cancel()` on a `PairingState`). It means the flow
368
+ was abandoned on purpose; check `e.name === 'AbortError'` and stay silent.
369
+
370
+ The two paths that are **not** failures are a user closing the dialog
371
+ (`AbortError`) and a holder declining (`FlowAbandonedError` with
372
+ `request_denied`). Everything a real integration should surface to the user as an
373
+ error is an `OAuthFlowError`, or the rare `FlowAbandonedError` of type `unknown`.
374
+
375
+ ## A complete example
376
+
377
+ A whole "Continue with ZOREAL" control in plain TypeScript — no framework — that
378
+ runs the auth-code flow, shows the pairing UI from `onState`, and hands
379
+ `{ code, code_verifier, nonce }` to your backend. **Your backend is where the
380
+ login is actually verified**: it exchanges the code at `/token` with its client
381
+ authentication, checks the ID token's signature, `iss`, `aud`, `exp` and
382
+ `nonce` against the JWKS, and reads `/userinfo`. Nothing the browser resolves is
383
+ trusted until it has.
384
+
385
+ ```ts
386
+ import {
387
+ startLogin,
388
+ OAuthFlowError,
389
+ FlowAbandonedError,
390
+ type PairingState,
391
+ } from '@zoreal/oauth2-js';
392
+
393
+ export function mountZorealButton(root: HTMLElement) {
394
+ const button = document.createElement('button');
395
+ button.textContent = 'Continue with ZOREAL';
396
+ const panel = document.createElement('div'); // holds the pairing UI
397
+ root.append(button, panel);
398
+
399
+ let handle: ReturnType<typeof startLogin> | null = null;
400
+
401
+ const renderPairing = (s: PairingState) => {
402
+ panel.replaceChildren();
403
+ if (s.appLink) {
404
+ panel.textContent = 'Opening the ZOREAL ID app…';
405
+ return;
406
+ }
407
+ if (s.qrUrl) {
408
+ const img = document.createElement('img');
409
+ img.src = s.qrUrl; // provider-served; never draw your own QR of pairUrl
410
+ img.alt = 'Scan with the ZOREAL ID app';
411
+ panel.append(img);
412
+ }
413
+ const status = document.createElement('p');
414
+ status.textContent = s.status; // pending | claimed | approved | ...
415
+ panel.append(status);
416
+ if (s.cancel) {
417
+ const cancel = document.createElement('button');
418
+ cancel.textContent = 'Cancel';
419
+ cancel.onclick = () => s.cancel!();
420
+ panel.append(cancel);
421
+ }
422
+ };
423
+
424
+ button.onclick = async () => {
425
+ handle?.cancel(); // one flow at a time
426
+ handle = startLogin({
427
+ flow: 'auth-code',
428
+ clientId: 'ast_your_asset_id',
429
+ scope: 'openid email profile.name',
430
+ onState: renderPairing,
431
+ });
432
+
433
+ try {
434
+ const { code, code_verifier, nonce } = await handle.promise;
435
+ panel.replaceChildren();
436
+
437
+ // Post all three to YOUR backend over TLS. Protect this route with your
438
+ // framework's normal CSRF / same-origin controls — the ZOREAL nonce
439
+ // protects the token, not your endpoint. The backend verifies before it
440
+ // trusts, then establishes the session.
441
+ const res = await fetch('/api/auth/zoreal', {
442
+ method: 'POST',
443
+ headers: { 'Content-Type': 'application/json' },
444
+ body: JSON.stringify({ code, code_verifier, nonce }),
445
+ });
446
+ if (!res.ok) throw new Error('backend rejected the login');
447
+ window.location.assign('/dashboard');
448
+ } catch (e) {
449
+ panel.replaceChildren();
450
+ if (e instanceof DOMException && e.name === 'AbortError') {
451
+ return; // the user closed the dialog; say nothing
452
+ }
453
+ if (e instanceof FlowAbandonedError && e.reason.type === 'request_denied') {
454
+ panel.textContent = 'Login declined. Try again when you are ready.';
455
+ return; // a human outcome, not an error to alarm on
456
+ }
457
+ if (e instanceof FlowAbandonedError && e.reason.type === 'request_expired') {
458
+ panel.textContent = 'That took too long. Try again.';
459
+ return;
460
+ }
461
+ if (e instanceof OAuthFlowError) {
462
+ panel.textContent = e.description ?? e.error; // provider's words, verbatim
463
+ return;
464
+ }
465
+ panel.textContent = 'Something went wrong. Try again.';
466
+ }
467
+ };
468
+ }
469
+ ```
470
+
471
+ For the no-backend case, swap `flow: 'auth-code'` for the default browser-direct
472
+ flow: `handle.promise` then resolves `{ credential }`, an ID token carrying only
473
+ `sub` and the proof of verification. It **still** has to be verified server-side
474
+ against the JWKS before you trust it — a token minted for someone else looks
475
+ identical in the browser.
476
+
240
477
  ## Writing a framework wrapper
241
478
 
242
479
  A wrapper owns exactly two things: calling `startLogin` on the user's
@@ -346,6 +583,34 @@ origin:
346
583
  while enrolling. The provider cancels an over-polling request rather than
347
584
  throttling it, so polling faster kills the login it is trying to save.
348
585
 
586
+ ## Security
587
+
588
+ Three things this package leans on, and where each stops:
589
+
590
+ - **The nonce binds the token to this login — it is not your CSRF token.** This
591
+ package generates a nonce, sends it with the pairing request, and resolves it
592
+ to you alongside the code. Handing it to your backend lets the backend confirm
593
+ the ID token was minted for *this* login rather than substituted. It does
594
+ **not** protect your own login route: guard `/api/auth/zoreal` (or wherever
595
+ you post the code) with your framework's normal CSRF / same-origin defences,
596
+ exactly as you would any endpoint that establishes a session.
597
+ - **PKCE is what proves the exchanger started the flow, not the nonce.** This
598
+ package generates the verifier, sends only its S256 challenge to `/pair`, and
599
+ keeps the verifier until the exchange. Whoever completes `/token` must present
600
+ the matching verifier, so an intercepted code alone is useless. PKCE is
601
+ mandatory for every client here — there is no `plain` fallback and never will
602
+ be.
603
+ - **The issuer must match the token's `iss` exactly.** It is compared, not
604
+ normalized. Production is `https://id.zoreal.com`, which is the default;
605
+ override `issuer` only when you have been given a specific non-production
606
+ provider URL to point at. Your backend must reject any token whose `iss` is
607
+ not exactly the issuer it expects.
608
+
609
+ And the rule the whole design rests on: this runs in a browser the threat model
610
+ treats as attacker-controlled, so nothing it resolves is trusted until your
611
+ backend has verified the ID token's signature, `iss`, `aud`, `exp` and `nonce`
612
+ against the JWKS. `unsafeClaims` is named for exactly that reason.
613
+
349
614
  ## The ZOREAL OAuth2 library family
350
615
 
351
616
  | Repository | Package | Role |
@@ -361,12 +626,6 @@ origin:
361
626
  | zoreal-oauth2-java | com.zoreal:oauth2 (Maven Central) | JVM backend |
362
627
  | zoreal-oauth2-dotnet | Zoreal.OAuth2 (NuGet) | .NET backend |
363
628
 
364
- ## Development against a local provider
365
-
366
- Pass `issuer` to `startLogin` . The issuer value must match the `iss` inside the
367
- tokens exactly - it is compared, not normalized. Sandbox clients accept any
368
- localhost origin.
369
-
370
629
  ## License
371
630
 
372
631
  MIT.
package/dist/index.cjs CHANGED
@@ -56,7 +56,7 @@ function unsafeClaims(idToken) {
56
56
 
57
57
  // src/wire.ts
58
58
  var WIRE_VERSION = 1;
59
- var SDK_VERSION = "0.1.3";
59
+ var SDK_VERSION = "0.1.5";
60
60
  var SDK_NAME = "@zoreal/oauth2-js";
61
61
  var DEFAULT_ISSUER = "https://id.zoreal.com";
62
62
  var POLL_INTERVAL_MS = 2e3;
@@ -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.3';\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.5';\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":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACWO,SAAS,aAAa,SAA0C;AACrE,MAAI;AACF,UAAM,UAAU,QAAQ,MAAM,GAAG,EAAE,CAAC,KAAK;AACzC,UAAM,MAAM,QAAQ,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG;AACxD,UAAM,SAAS,MAAM,IAAI,QAAQ,IAAK,IAAI,SAAS,KAAM,CAAC;AAC1D,WAAO,KAAK;AAAA,MACV,IAAI,YAAY,EAAE,OAAO,WAAW,KAAK,KAAK,MAAM,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC;AAAA,IAChF;AAAA,EACF,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;;;ACQO,IAAM,eAAe;AACrB,IAAM,cAAc;AACpB,IAAM,WAAW;AACjB,IAAM,iBAAiB;AAGvB,IAAM,mBAAmB;AAEzB,IAAM,6BAA6B;;;AChBnC,IAAM,iBAAN,cAA6B,MAAM;AAAA,EACxC,YACS,OACA,aACP;AACA,UAAM,eAAe,KAAK;AAHnB;AACA;AAAA,EAGT;AACF;AAEO,IAAM,qBAAN,cAAiC,MAAM;AAAA,EAC5C,YAAmB,QAAuB;AACxC,UAAM,OAAO,eAAe,OAAO,IAAI;AADtB;AAAA,EAEnB;AACF;AAeA,eAAe,UAAU,UAAsD;AAC7E,MAAI;AACF,WAAQ,MAAM,SAAS,KAAK;AAAA,EAC9B,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AAEA,eAAsB,aACpB,QACA,QAC4B;AAC5B,QAAM,WAAW,MAAM,MAAM,GAAG,MAAM,SAAS;AAAA,IAC7C,QAAQ;AAAA,IACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,IAC9C,MAAM,KAAK,UAAU;AAAA,MACnB,GAAG;AAAA,MACH,uBAAuB;AAAA,MACvB,cAAc;AAAA,MACd,KAAK,GAAG,QAAQ,IAAI,WAAW;AAAA,IACjC,CAAC;AAAA,EACH,CAAC;AAED,QAAM,OAAO,MAAM,UAAU,QAAQ;AACrC,MAAI,CAAC,SAAS,IAAI;AAIhB,UAAM,IAAI;AAAA,MACP,KAAK,SAAuB;AAAA,MAC5B,KAAK,qBAAgC,qCAAqC,SAAS,MAAM;AAAA,IAC5F;AAAA,EACF;AACA,SAAO;AACT;AAEA,IAAM,QAAQ,CAAC,IAAY,WACzB,IAAI,QAAc,CAAC,SAAS,WAAW;AACrC,MAAI,QAAQ,SAAS;AACnB,WAAO,IAAI,aAAa,WAAW,YAAY,CAAC;AAChD;AAAA,EACF;AACA,QAAM,IAAI,WAAW,SAAS,EAAE;AAChC,UAAQ,iBAAiB,SAAS,MAAM;AACtC,iBAAa,CAAC;AACd,WAAO,IAAI,aAAa,WAAW,YAAY,CAAC;AAAA,EAClD,CAAC;AACH,CAAC;AAOH,eAAsB,kBACpB,QACA,WACA,SACA,QACiB;AACjB,aAAS;AACP,UAAM,WAAW,MAAM,MAAM,GAAG,MAAM,SAAS,mBAAmB,SAAS,CAAC,WAAW;AAAA,MACrF;AAAA,IACF,CAAC;AACD,UAAM,OAAQ,MAAM,UAAU,QAAQ;AAEtC,QAAI,CAAC,SAAS,IAAI;AAChB,YAAM,IAAI;AAAA,QACP,KAAK,SAAuB;AAAA,QAC7B,KAAK,qBAAqB,0BAA0B,SAAS,MAAM;AAAA,MACrE;AAAA,IACF;AAEA,cAAU;AAAA,MACR,QAAQ,KAAK;AAAA,MACb,WAAW,KAAK;AAAA,MAChB,mBAAmB,KAAK;AAAA,IAC1B,CAAC;AAED,YAAQ,KAAK,QAAQ;AAAA,MACnB,KAAK;AACH,YAAI,CAAC,KAAK,MAAM;AACd,gBAAM,IAAI,eAAe,gBAAgB,qCAAqC;AAAA,QAChF;AACA,eAAO,KAAK;AAAA,MACd,KAAK;AACH,cAAM,IAAI,mBAAmB,EAAE,MAAM,kBAAkB,aAAa,KAAK,kBAAkB,CAAC;AAAA,MAC9F,KAAK;AACH,cAAM,IAAI,mBAAmB,EAAE,MAAM,mBAAmB,aAAa,KAAK,kBAAkB,CAAC;AAAA,MAC/F,KAAK;AAIH,cAAM,IAAI,mBAAmB;AAAA,UAC3B,MAAM;AAAA,UACN,aAAa,KAAK,qBAAqB;AAAA,QACzC,CAAC;AAAA,MACH,KAAK;AACH,cAAM,MAAM,4BAA4B,MAAM;AAC9C;AAAA,MACF;AACE,cAAM,MAAM,kBAAkB,MAAM;AAAA,IACxC;AAAA,EACF;AACF;AASA,eAAsB,aACpB,QACA,OACwB;AACxB,QAAM,WAAW,MAAM,MAAM,GAAG,MAAM,UAAU;AAAA,IAC9C,QAAQ;AAAA,IACR,SAAS,EAAE,gBAAgB,oCAAoC;AAAA,IAC/D,MAAM,IAAI,gBAAgB;AAAA,MACxB,YAAY;AAAA,MACZ,MAAM,MAAM;AAAA,MACZ,eAAe,MAAM;AAAA,MACrB,WAAW,MAAM;AAAA,IACnB,CAAC;AAAA,EACH,CAAC;AAED,QAAM,OAAQ,MAAM,UAAU,QAAQ;AACtC,MAAI,CAAC,SAAS,MAAM,KAAK,OAAO;AAC9B,UAAM,IAAI;AAAA,MACP,KAAK,SAAuB;AAAA,MAC7B,KAAK,qBAAqB,0BAA0B,SAAS,MAAM;AAAA,IACrE;AAAA,EACF;AACA,SAAO;AACT;AAGO,SAAS,oBAA6B;AAC3C,MAAI,OAAO,cAAc,YAAa,QAAO;AAC7C,SAAO,4BAA4B,KAAK,UAAU,SAAS;AAC7D;;;AC1LA,IAAM,iBAAiB;AAEvB,IAAM,YAAY,CAAC,UACjB,KAAK,OAAO,aAAa,GAAG,KAAK,CAAC,EAC/B,QAAQ,OAAO,GAAG,EAClB,QAAQ,OAAO,GAAG,EAClB,QAAQ,OAAO,EAAE;AAEf,SAAS,mBAA2B;AACzC,QAAM,QAAQ,IAAI,WAAW,cAAc;AAC3C,SAAO,gBAAgB,KAAK;AAC5B,SAAO,UAAU,KAAK;AACxB;AAEA,eAAsB,cAAc,UAAmC;AACrE,QAAM,SAAS,MAAM,OAAO,OAAO,OAAO,WAAW,IAAI,YAAY,EAAE,OAAO,QAAQ,CAAC;AACvF,SAAO,UAAU,IAAI,WAAW,MAAM,CAAC;AACzC;AAEO,SAAS,gBAAwB;AACtC,QAAM,QAAQ,IAAI,WAAW,EAAE;AAC/B,SAAO,gBAAgB,KAAK;AAC5B,SAAO,UAAU,KAAK;AACxB;;;ACSO,SAAS,WACd,SACyE;AACzE,MAAI,aAAa,WAAW,QAAQ,YAAY,YAAY;AAK1D,UAAM,IAAI;AAAA,MACR;AAAA,IAGF;AAAA,EACF;AAEA,QAAM,OAAO,QAAQ,QAAQ;AAC7B,QAAM,SAAS,QAAQ,UAAU;AACjC,QAAM,aAAa,IAAI,gBAAgB;AAEvC,QAAM,UAKF,CAAC;AAEL,QAAM,SAAS,MAAM,WAAW,MAAM;AAEtC,QAAM,MAAM,YAAoE;AAC9E,UAAM,WAAW,iBAAiB;AAClC,UAAM,QAAQ,cAAc;AAC5B,UAAM,QAAQ,cAAc;AAE5B,QAAI;AACF,YAAM,UAAU,MAAM,aAAa,QAAQ;AAAA,QACzC,WAAW,QAAQ;AAAA,QACnB,OAAO,QAAQ,SAAS;AAAA,QACxB;AAAA,QACA;AAAA,QACA,gBAAgB,MAAM,cAAc,QAAQ;AAAA,QAC5C,cAAc,SAAS,cAAe,QAAiC,eAAe;AAAA,QACtF,YAAY,MAAM,QAAQ,QAAQ,UAAU,IACxC,QAAQ,WAAW,KAAK,GAAG,IAC3B,QAAQ;AAAA,QACZ,SAAS,QAAQ;AAAA,QACjB,QAAQ,QAAQ;AAAA,QAChB,QAAQ,QAAQ;AAAA,MAClB,CAAC;AAED,UAAI;AACJ,UAAI,WAAqB;AAEzB,UAAI,UAAU,SAAS;AAErB,eAAO,QAAQ;AACf,mBAAW;AAAA,MACb,OAAO;AACL,cAAM,aACJ,QAAQ,YAAY,UAAW,QAAQ,YAAY,QAAQ,kBAAkB;AAC/E,mBAAW,aAAa,aAAa;AAErC,gBAAQ,YAAY,QAAQ;AAC5B,gBAAQ,UAAU,QAAQ;AAC1B,gBAAQ,QAAQ,GAAG,MAAM,SAAS,mBAAmB,QAAQ,UAAU,CAAC;AACxE,gBAAQ,UAAU;AAKlB,cAAM,eAAe;AAAA,UACnB,SAAS,QAAQ;AAAA,UACjB,OAAO,QAAQ;AAAA,UACf,SAAS;AAAA,UACT;AAAA,QACF;AAIA,gBAAQ,UAAU,EAAE,QAAQ,WAAW,WAAW,QAAQ,YAAY,GAAG,aAAa,CAAC;AAEvF,YAAI,cAAc,OAAO,WAAW,aAAa;AAK/C,iBAAO,SAAS,OAAO,QAAQ,QAAQ;AAAA,QACzC;AAEA,eAAO,MAAM;AAAA,UACX;AAAA,UACA,QAAQ;AAAA,UACR,CAAC,MAAM,QAAQ,UAAU,EAAE,GAAG,GAAG,GAAG,aAAa,CAAC;AAAA,UAClD,WAAW;AAAA,QACb;AAAA,MACF;AAEA,UAAI,SAAS,aAAa;AACxB,cAAMA,YAA+B;AAAA,UACnC;AAAA,UACA,OAAO,QAAQ,SAAS;AAAA,UACxB,WAAW,QAAQ;AAAA,UACnB,eAAe;AAAA,UACf;AAAA,QACF;AACA,eAAOA;AAAA,MACT;AAEA,YAAM,SAAS,MAAM,aAAa,QAAQ;AAAA,QACxC;AAAA,QACA,eAAe;AAAA,QACf,WAAW,QAAQ;AAAA,MACrB,CAAC;AACD,YAAM,SAAS,aAAa,OAAO,QAAQ;AAC3C,YAAM,WAAqC;AAAA,QACzC,YAAY,OAAO;AAAA,QACnB,UAAU,QAAQ;AAAA,QAClB,WAAW;AAAA,QACX,KAAM,OAAO,OAAoB;AAAA,MACnC;AACA,aAAO;AAAA,IACT,SAAS,GAAG;AAMV,UAAI,aAAa,gBAAgB,EAAE,SAAS,aAAc,OAAM;AAChE,UAAI,aAAa,kBAAkB,aAAa,mBAAoB,OAAM;AAC1E,YAAM,IAAI,mBAAmB;AAAA,QAC3B,MAAM;AAAA,QACN,aAAa,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC;AAAA,MACxD,CAAC;AAAA,IACH;AAAA,EACF;AAEA,QAAM,UAAU,IAAI;AAIpB,UAAQ,MAAM,MAAM;AAAA,EAAC,CAAC;AAEtB,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,IAAI,YAAY;AACd,aAAO,QAAQ;AAAA,IACjB;AAAA,IACA,IAAI,UAAU;AACZ,aAAO,QAAQ;AAAA,IACjB;AAAA,IACA,IAAI,QAAQ;AACV,aAAO,QAAQ;AAAA,IACjB;AAAA,IACA,IAAI,UAAU;AACZ,aAAO,QAAQ;AAAA,IACjB;AAAA,EACF;AACF;","names":["response"]}
package/dist/index.d.cts CHANGED
@@ -178,7 +178,7 @@ declare function startLogin(options: AuthCodeLoginOptions): LoginHandle<ZorealCo
178
178
  * auth-code mode leaves it to the RP backend.
179
179
  */
180
180
  declare const WIRE_VERSION = 1;
181
- declare const SDK_VERSION = "0.1.3";
181
+ declare const SDK_VERSION = "0.1.5";
182
182
  declare const SDK_NAME = "@zoreal/oauth2-js";
183
183
  declare const DEFAULT_ISSUER = "https://id.zoreal.com";
184
184
  /** Pending TTL is short. Poll gently; over-polling cancels the request. */
package/dist/index.d.ts CHANGED
@@ -178,7 +178,7 @@ declare function startLogin(options: AuthCodeLoginOptions): LoginHandle<ZorealCo
178
178
  * auth-code mode leaves it to the RP backend.
179
179
  */
180
180
  declare const WIRE_VERSION = 1;
181
- declare const SDK_VERSION = "0.1.3";
181
+ declare const SDK_VERSION = "0.1.5";
182
182
  declare const SDK_NAME = "@zoreal/oauth2-js";
183
183
  declare const DEFAULT_ISSUER = "https://id.zoreal.com";
184
184
  /** Pending TTL is short. Poll gently; over-polling cancels the request. */
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ function unsafeClaims(idToken) {
14
14
 
15
15
  // src/wire.ts
16
16
  var WIRE_VERSION = 1;
17
- var SDK_VERSION = "0.1.3";
17
+ var SDK_VERSION = "0.1.5";
18
18
  var SDK_NAME = "@zoreal/oauth2-js";
19
19
  var DEFAULT_ISSUER = "https://id.zoreal.com";
20
20
  var POLL_INTERVAL_MS = 2e3;
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.3';\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.5';\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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zoreal/oauth2-js",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Login with ZOREAL for the browser, framework-free. A ZOREAL Verified Proof-of-Human behind every sign-in.",
5
5
  "license": "MIT",
6
6
  "repository": {