@zoreal/oauth2-js 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bynn Intelligence, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,301 @@
1
+ # @zoreal/oauth2-js
2
+
3
+ Login with ZOREAL for the browser, framework-free: a ZOREAL Verified
4
+ Proof-of-Human behind every sign-in.
5
+
6
+ This is the wire core of
7
+ [`@zoreal/oauth2-react`](https://github.com/Bynn-Intelligence/zoreal-oauth2-react)
8
+ without the React: the pairing (QR or app link), the polling, PKCE, and the
9
+ browser-side code exchange, exposed as one imperative call. Use it directly
10
+ from plain JavaScript, or build a Vue, Svelte, or Angular wrapper on it; the
11
+ React package is what that wrapper looks like when it is finished.
12
+
13
+ ```
14
+ @zoreal/oauth2-js (this package) the flow: pairing, polling, PKCE, exchange
15
+ your wrapper or plain JS the UI: render what onState carries
16
+ ```
17
+
18
+ ## Status
19
+
20
+ Early release. The package implements wire protocol v1. The hosted ZOREAL
21
+ login service is still rolling out, so treat this as a preview: the API is
22
+ stable, but end-to-end sign-in against production is not available everywhere
23
+ yet. This note is removed once the service is generally available.
24
+
25
+ ## Install
26
+
27
+ ```sh
28
+ npm install @zoreal/oauth2-js
29
+ ```
30
+
31
+ Zero runtime dependencies. ESM and CJS. Browser APIs only (`fetch`,
32
+ `crypto.subtle`); any evergreen browser has everything it needs.
33
+
34
+ ## Two flows: pick by whether you need the user's details
35
+
36
+ - **You have a backend and want the user's email or name** (most apps): use
37
+ `flow: 'auth-code'`. Your backend gets the email, name, and verification
38
+ details from `/userinfo`. Start here.
39
+ - **You have no backend and only need to know "this is a verified, unique
40
+ human, and the same one as last time"**: use the default browser-direct
41
+ flow. It returns a stable per-user identifier and proof of verification, but
42
+ no email or name. Email and other personal details are never placed in a
43
+ browser-side token; that is what the auth-code flow and your backend are
44
+ for.
45
+
46
+ ## Quick start: auth-code (email and name, needs your backend)
47
+
48
+ ```ts
49
+ import { startLogin } from '@zoreal/oauth2-js';
50
+
51
+ // On the user's click, never on page load:
52
+ const handle = startLogin({
53
+ flow: 'auth-code',
54
+ clientId: 'ast_your_asset_id',
55
+ scope: 'openid email profile.name',
56
+ onState: (s) => {
57
+ // Render the pairing UI from this: s.qrUrl in an <img>, s.status as text,
58
+ // s.cancel on your close button. Every callback carries all of it.
59
+ renderPairing(s);
60
+ },
61
+ });
62
+
63
+ const { code, code_verifier, nonce } = await handle.promise;
64
+ // Send ALL THREE to your backend over TLS. Your backend calls POST /token
65
+ // with the code, the verifier and its client authentication, verifies the
66
+ // ID token (including the nonce), then reads email and name from /userinfo.
67
+ await fetch('/api/auth/zoreal', {
68
+ method: 'POST',
69
+ headers: { 'Content-Type': 'application/json' },
70
+ body: JSON.stringify({ code, code_verifier, nonce }),
71
+ });
72
+ ```
73
+
74
+ ## Quick start: browser-direct (no backend, pseudonymous)
75
+
76
+ ```ts
77
+ import { startLogin } from '@zoreal/oauth2-js';
78
+
79
+ const handle = startLogin({
80
+ clientId: 'ast_your_asset_id',
81
+ onState: (s) => renderPairing(s),
82
+ });
83
+
84
+ const { credential } = await handle.promise;
85
+ // `credential` is an ID token carrying a stable per-user identifier (`sub`)
86
+ // and proof the person is a verified, unique human. No email, no name: use
87
+ // the auth-code flow above for those. Verify it on your server against the
88
+ // JWKS before trusting it.
89
+ ```
90
+
91
+ On desktop, `onState` gives you a QR to render; the user scans it with their
92
+ phone and approves in the ZOREAL ID app. On a phone, `startLogin` opens the
93
+ app directly through the pairing link and the promise settles when the user
94
+ returns. Either way your page just awaits `handle.promise`.
95
+
96
+ ## The handle
97
+
98
+ `startLogin` returns synchronously with everything a UI needs to drive the
99
+ flow:
100
+
101
+ | Field | What it is |
102
+ |---|---|
103
+ | `promise` | resolves with the mode's result; rejects with `OAuthFlowError`, `FlowAbandonedError`, or an `AbortError` after `cancel()` |
104
+ | `cancel()` | abandons the flow: stops the poll, rejects the promise |
105
+ | `requestId` | the pairing request id, once the provider has created it |
106
+ | `pairUrl` | the pairing link, once created. The same URL in QR and app link |
107
+ | `qrUrl` | the provider-served SVG of `pairUrl`. Put it in an `<img>`; do not draw your own |
108
+ | `appLink` | true when the flow resolved to the app link (mobile) rather than a QR |
109
+
110
+ `requestId`, `pairUrl`, `qrUrl` and `appLink` are `undefined` until the
111
+ pairing request exists (one round-trip), and stay `undefined` when
112
+ `prompt: 'none'` resolves silently. The same four values also arrive on every
113
+ `onState` callback, which is the reliable place to render from.
114
+
115
+ `onState` receives a `PairingState` on every change:
116
+ `status` (`pending | claimed | approved | denied | expired | enrolling`),
117
+ `expiresIn`, `enrolmentDeadline`, `pairUrl`, `qrUrl`, `appLink`, and
118
+ `cancel`.
119
+
120
+ ## What resolves, per mode
121
+
122
+ Browser-direct:
123
+
124
+ ```ts
125
+ { credential: string, // the ID token; verify server-side against the JWKS
126
+ clientId: string,
127
+ select_by: 'qr' | 'app_link' | 'device' | 'session',
128
+ acr: 'zoreal.live' | 'zoreal.device' | 'zoreal.session' }
129
+ ```
130
+
131
+ Auth-code:
132
+
133
+ ```ts
134
+ { code: string, // single-use, short-lived
135
+ code_verifier: string, // PKCE; your backend needs it to complete the exchange
136
+ nonce: string, // your backend checks it against the ID token's nonce claim
137
+ scope: string,
138
+ app_state?: string } // whatever you passed in, echoed back
139
+ ```
140
+
141
+ `ux_mode: 'redirect'` is not supported: it would put the PKCE verifier in a
142
+ URL, which is a credential in every access log on the path. `startLogin`
143
+ throws rather than doing that.
144
+
145
+ ## API
146
+
147
+ | Export | What it does |
148
+ |---|---|
149
+ | `startLogin(options)` | the whole flow: pairing, polling, and (browser-direct) the exchange. Returns the handle above |
150
+ | `startPairing(issuer, params)` | `POST {issuer}/pair`, returns `{ request_id, pair_url, expires_in }` or an immediate `{ code }` |
151
+ | `pollUntilApproved(issuer, requestId, onState?, signal?)` | polls `/pair/:id/status` at the fixed cadence until a code or a terminal state |
152
+ | `exchangeCode(issuer, { code, code_verifier, client_id })` | `POST {issuer}/token`: public client, PKCE, no secret |
153
+ | `generateVerifier()` / `challengeS256(v)` / `generateState()` | PKCE and state material, S256 only |
154
+ | `unsafeClaims(idToken)` | reads claims without verifying. Convenience only; verification happens server-side |
155
+ | `isMobileUserAgent()` | whether this user agent gets the app link rather than a QR |
156
+
157
+ Errors: `OAuthFlowError` (the provider refused; `error` is the OAuth code,
158
+ `description` is the provider's reason verbatim) and `FlowAbandonedError` (a
159
+ human outcome: `reason.type` is `request_denied`, `request_expired`,
160
+ `enrolment_abandoned`, or `unknown` for failures that never reached the
161
+ provider). `cancel()` rejects with a `DOMException` named `AbortError`.
162
+
163
+ All types are exported: `PairingState`, `ZorealCredentialResponse`,
164
+ `ZorealCodeResponse`, `StartLoginOptions`, `BrowserDirectLoginOptions`,
165
+ `AuthCodeLoginOptions`, `LoginHandle`, `ErrorCode`, `NonOAuthError`,
166
+ `SelectBy`, `AcrValue`, and the wire shapes.
167
+
168
+ ## Writing a framework wrapper
169
+
170
+ A wrapper owns exactly two things: calling `startLogin` on the user's
171
+ gesture, and rendering what `onState` carries. Everything else - PKCE, state,
172
+ nonce, poll cadence, cancellation - is this package's job. A minimal Vue 3
173
+ composable:
174
+
175
+ ```ts
176
+ // useZorealLogin.ts
177
+ import { onUnmounted, ref } from 'vue';
178
+ import {
179
+ startLogin,
180
+ type PairingState,
181
+ type ZorealCredentialResponse,
182
+ } from '@zoreal/oauth2-js';
183
+
184
+ export function useZorealLogin(clientId: string) {
185
+ const pairing = ref<PairingState | null>(null);
186
+ const credential = ref<ZorealCredentialResponse | null>(null);
187
+ const error = ref<string | null>(null);
188
+ let active: { cancel: () => void } | null = null;
189
+
190
+ const login = () => {
191
+ active?.cancel();
192
+ const handle = startLogin({
193
+ clientId,
194
+ onState: (s) => (pairing.value = s),
195
+ });
196
+ active = handle;
197
+ handle.promise
198
+ .then((r) => (credential.value = r))
199
+ .catch((e) => {
200
+ if (e?.name !== 'AbortError') error.value = e.message;
201
+ })
202
+ .finally(() => (pairing.value = null));
203
+ };
204
+
205
+ // A component unmounting mid-login must stop the poll: the provider
206
+ // cancels over-polled requests, and an orphaned poll is how one happens.
207
+ onUnmounted(() => active?.cancel());
208
+
209
+ return { login, pairing, credential, error };
210
+ }
211
+ ```
212
+
213
+ And the template renders the state:
214
+
215
+ ```vue
216
+ <template>
217
+ <button @click="login">Continue with ZOREAL</button>
218
+ <div v-if="pairing">
219
+ <img v-if="!pairing.appLink" :src="pairing.qrUrl" alt="Scan with the ZOREAL ID app" />
220
+ <p>{{ pairing.status }}</p>
221
+ <button @click="pairing.cancel">Cancel</button>
222
+ </div>
223
+ </template>
224
+ ```
225
+
226
+ The same shape ports to Svelte (a store fed by `onState`) or Angular (a
227
+ service exposing an observable). The rules a wrapper must keep:
228
+
229
+ - Render `qrUrl` in an `<img>`; never draw your own QR of `pairUrl`.
230
+ - Call `cancel()` on unmount or navigation. Do not add your own retry loop:
231
+ the poll cadence is fixed because over-polling cancels the request
232
+ server-side.
233
+ - Show `description` from errors verbatim. It is the provider's own reason,
234
+ and rewriting it hides the only signal telling an integrator what happened.
235
+
236
+ ## What your page needs to allow
237
+
238
+ The package loads no third-party script, no stylesheet, no font, and has zero
239
+ runtime dependencies. Two things touch the network, both on the ZOREAL
240
+ origin:
241
+
242
+ | CSP directive | Value | Why |
243
+ |---|---|---|
244
+ | `connect-src` | `https://id.zoreal.com` | starting the pairing, polling it, and (browser-direct) the code exchange |
245
+ | `img-src` | `https://id.zoreal.com` | the QR image, served by the provider so it stays correct and current |
246
+
247
+ ## Things worth knowing before you integrate
248
+
249
+ - **The ID token never carries personal data.** `sub`, timing, `acr`/`amr`,
250
+ the assurance block, and - if registered - `age_over_*` booleans and
251
+ `nationality`. Email, names, birthdate and document fields come only from
252
+ `/userinfo`, read by your backend in the auth-code flow.
253
+ - **The access token lives 10 minutes.** Your backend should read `/userinfo`
254
+ while handling the login, not store the token for later.
255
+ - **`sub` is pairwise per verified domain.** It is the right account key and
256
+ it is derived from your registered sector: changing your asset's domain
257
+ rotates every `sub` you have stored. Plan domain changes as a migration.
258
+ - **ES256 only.** The provider signs ID tokens with nothing else, and your
259
+ backend should refuse other algorithms rather than negotiating.
260
+ - **Always hand the nonce to your backend.** This package generates it and
261
+ resolves it alongside the code; without it your backend cannot tell a
262
+ substituted ID token from the real one.
263
+ - **Email is a deliberate choice.** It is a Tier B scope precisely because a
264
+ shared email defeats the unlinkability the pairwise `sub` provides. Request
265
+ it because you need it, not because the checkbox is familiar.
266
+ - **Sandbox clients accept localhost origins; production clients do not.**
267
+ Registration lives in the ZOREAL dashboard on the asset's OAuth2 tab; Tier B
268
+ scopes (email, profile.\*) need a confidential client on a verified domain,
269
+ and a public client requesting them is refused at the pairing step.
270
+ - **No secret has a home here.** `startLogin` takes no client secret and
271
+ never will. Browser-direct mode is a public client with PKCE; auth-code
272
+ mode leaves client authentication to your backend, where the secret lives.
273
+ - **The poll cadence is not a suggestion.** 2000ms while pending, 5000ms
274
+ while enrolling. The provider cancels an over-polling request rather than
275
+ throttling it, so polling faster kills the login it is trying to save.
276
+
277
+ ## The ZOREAL OAuth2 library family
278
+
279
+ | Repository | Package | Role |
280
+ |---|---|---|
281
+ | zoreal-oauth2-react | @zoreal/oauth2-react (npm) | React frontend: the button, the QR, the polling |
282
+ | zoreal-oauth2-js | @zoreal/oauth2-js (npm) | Framework-free browser core |
283
+ | zoreal-oauth2-react-native | @zoreal/oauth2-react-native (npm) | React Native frontend |
284
+ | zoreal-oauth2-node | @zoreal/oauth2-node (npm) | Node.js backend |
285
+ | zoreal-oauth2-ruby | zoreal-oauth2 (RubyGems) | Ruby backend |
286
+ | zoreal-oauth2-python | zoreal-oauth2 (PyPI) | Python backend |
287
+ | zoreal-oauth2-php | zoreal/oauth2 (Packagist) | PHP backend |
288
+ | zoreal-oauth2-go | github.com/Bynn-Intelligence/zoreal-oauth2-go | Go backend |
289
+ | zoreal-oauth2-java | com.zoreal:oauth2 (Maven Central) | JVM backend |
290
+ | zoreal-oauth2-dotnet | Zoreal.OAuth2 (NuGet) | .NET backend |
291
+
292
+ ## Development against a local provider
293
+
294
+ Pass `issuer` to `startLogin` (for the Bynn stack:
295
+ `https://rails.bynn.io/id`). The issuer value must match the `iss` inside the
296
+ tokens exactly - it is compared, not normalized. Sandbox clients accept any
297
+ localhost origin.
298
+
299
+ ## License
300
+
301
+ MIT.
package/dist/index.cjs ADDED
@@ -0,0 +1,330 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ DEFAULT_ISSUER: () => DEFAULT_ISSUER,
24
+ FlowAbandonedError: () => FlowAbandonedError,
25
+ OAuthFlowError: () => OAuthFlowError,
26
+ POLL_INTERVAL_ENROLLING_MS: () => POLL_INTERVAL_ENROLLING_MS,
27
+ POLL_INTERVAL_MS: () => POLL_INTERVAL_MS,
28
+ SDK_NAME: () => SDK_NAME,
29
+ SDK_VERSION: () => SDK_VERSION,
30
+ WIRE_VERSION: () => WIRE_VERSION,
31
+ challengeS256: () => challengeS256,
32
+ exchangeCode: () => exchangeCode,
33
+ generateState: () => generateState,
34
+ generateVerifier: () => generateVerifier,
35
+ isMobileUserAgent: () => isMobileUserAgent,
36
+ pollUntilApproved: () => pollUntilApproved,
37
+ startLogin: () => startLogin,
38
+ startPairing: () => startPairing,
39
+ unsafeClaims: () => unsafeClaims
40
+ });
41
+ module.exports = __toCommonJS(index_exports);
42
+
43
+ // src/jwt.ts
44
+ function unsafeClaims(idToken) {
45
+ try {
46
+ const payload = idToken.split(".")[1] ?? "";
47
+ const b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
48
+ const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
49
+ return JSON.parse(
50
+ new TextDecoder().decode(Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)))
51
+ );
52
+ } catch {
53
+ return {};
54
+ }
55
+ }
56
+
57
+ // src/wire.ts
58
+ var WIRE_VERSION = 1;
59
+ var SDK_VERSION = "0.1.1";
60
+ var SDK_NAME = "@zoreal/oauth2-js";
61
+ var DEFAULT_ISSUER = "https://id.zoreal.com";
62
+ var POLL_INTERVAL_MS = 2e3;
63
+ var POLL_INTERVAL_ENROLLING_MS = 5e3;
64
+
65
+ // src/pairing.ts
66
+ var OAuthFlowError = class extends Error {
67
+ constructor(error, description) {
68
+ super(description ?? error);
69
+ this.error = error;
70
+ this.description = description;
71
+ }
72
+ };
73
+ var FlowAbandonedError = class extends Error {
74
+ constructor(reason) {
75
+ super(reason.description ?? reason.type);
76
+ this.reason = reason;
77
+ }
78
+ };
79
+ async function parseJson(response) {
80
+ try {
81
+ return await response.json();
82
+ } catch {
83
+ return {};
84
+ }
85
+ }
86
+ async function startPairing(issuer, params) {
87
+ const response = await fetch(`${issuer}/pair`, {
88
+ method: "POST",
89
+ headers: { "Content-Type": "application/json" },
90
+ body: JSON.stringify({
91
+ ...params,
92
+ code_challenge_method: "S256",
93
+ wire_version: WIRE_VERSION,
94
+ sdk: `${SDK_NAME}/${SDK_VERSION}`
95
+ })
96
+ });
97
+ const body = await parseJson(response);
98
+ if (!response.ok) {
99
+ throw new OAuthFlowError(
100
+ body.error ?? "server_error",
101
+ body.error_description ?? `The provider refused the request (${response.status})`
102
+ );
103
+ }
104
+ return body;
105
+ }
106
+ var sleep = (ms, signal) => new Promise((resolve, reject) => {
107
+ if (signal?.aborted) {
108
+ reject(new DOMException("aborted", "AbortError"));
109
+ return;
110
+ }
111
+ const t = setTimeout(resolve, ms);
112
+ signal?.addEventListener("abort", () => {
113
+ clearTimeout(t);
114
+ reject(new DOMException("aborted", "AbortError"));
115
+ });
116
+ });
117
+ async function pollUntilApproved(issuer, requestId, onState, signal) {
118
+ for (; ; ) {
119
+ const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {
120
+ signal
121
+ });
122
+ const body = await parseJson(response);
123
+ if (!response.ok) {
124
+ throw new OAuthFlowError(
125
+ body.error ?? "server_error",
126
+ body.error_description ?? `Pairing status failed (${response.status})`
127
+ );
128
+ }
129
+ onState?.({
130
+ status: body.status,
131
+ expiresIn: body.expires_in,
132
+ enrolmentDeadline: body.enrolment_deadline
133
+ });
134
+ switch (body.status) {
135
+ case "approved":
136
+ if (!body.code) {
137
+ throw new OAuthFlowError("server_error", "approved with no authorization code");
138
+ }
139
+ return body.code;
140
+ case "denied":
141
+ throw new FlowAbandonedError({ type: "request_denied", description: body.error_description });
142
+ case "expired":
143
+ throw new FlowAbandonedError({ type: "request_expired", description: body.error_description });
144
+ case "cancelled":
145
+ throw new FlowAbandonedError({
146
+ type: "request_expired",
147
+ description: body.error_description ?? "the provider cancelled the pairing request"
148
+ });
149
+ case "enrolling":
150
+ await sleep(POLL_INTERVAL_ENROLLING_MS, signal);
151
+ break;
152
+ default:
153
+ await sleep(POLL_INTERVAL_MS, signal);
154
+ }
155
+ }
156
+ }
157
+ async function exchangeCode(issuer, input) {
158
+ const response = await fetch(`${issuer}/token`, {
159
+ method: "POST",
160
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
161
+ body: new URLSearchParams({
162
+ grant_type: "authorization_code",
163
+ code: input.code,
164
+ code_verifier: input.code_verifier,
165
+ client_id: input.client_id
166
+ })
167
+ });
168
+ const body = await parseJson(response);
169
+ if (!response.ok || body.error) {
170
+ throw new OAuthFlowError(
171
+ body.error ?? "server_error",
172
+ body.error_description ?? `Token exchange failed (${response.status})`
173
+ );
174
+ }
175
+ return body;
176
+ }
177
+ function isMobileUserAgent() {
178
+ if (typeof navigator === "undefined") return false;
179
+ return /android|iphone|ipad|ipod/i.test(navigator.userAgent);
180
+ }
181
+
182
+ // src/pkce.ts
183
+ var VERIFIER_BYTES = 32;
184
+ var base64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
185
+ function generateVerifier() {
186
+ const bytes = new Uint8Array(VERIFIER_BYTES);
187
+ crypto.getRandomValues(bytes);
188
+ return base64url(bytes);
189
+ }
190
+ async function challengeS256(verifier) {
191
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
192
+ return base64url(new Uint8Array(digest));
193
+ }
194
+ function generateState() {
195
+ const bytes = new Uint8Array(16);
196
+ crypto.getRandomValues(bytes);
197
+ return base64url(bytes);
198
+ }
199
+
200
+ // src/login.ts
201
+ function startLogin(options) {
202
+ if ("ux_mode" in options && options.ux_mode === "redirect") {
203
+ throw new Error(
204
+ "@zoreal/oauth2-js: ux_mode 'redirect' is not supported. Use the default 'popup' shape and post the code and code_verifier from the resolved promise to your backend."
205
+ );
206
+ }
207
+ const flow = options.flow ?? "browser-direct";
208
+ const issuer = options.issuer ?? DEFAULT_ISSUER;
209
+ const controller = new AbortController();
210
+ const surface = {};
211
+ const cancel = () => controller.abort();
212
+ const run = async () => {
213
+ const verifier = generateVerifier();
214
+ const state = generateState();
215
+ const nonce = generateState();
216
+ try {
217
+ const started = await startPairing(issuer, {
218
+ client_id: options.clientId,
219
+ scope: options.scope ?? "openid",
220
+ state,
221
+ nonce,
222
+ code_challenge: await challengeS256(verifier),
223
+ redirect_uri: flow === "auth-code" ? options.redirect_uri : void 0,
224
+ acr_values: Array.isArray(options.acr_values) ? options.acr_values.join(" ") : options.acr_values,
225
+ max_age: options.max_age,
226
+ prompt: options.prompt,
227
+ locale: options.locale
228
+ });
229
+ let code;
230
+ let selectBy = "device";
231
+ if ("code" in started) {
232
+ code = started.code;
233
+ selectBy = "session";
234
+ } else {
235
+ const useAppLink = options.display === "link" || options.display !== "qr" && isMobileUserAgent();
236
+ selectBy = useAppLink ? "app_link" : "qr";
237
+ surface.requestId = started.request_id;
238
+ surface.pairUrl = started.pair_url;
239
+ surface.qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;
240
+ surface.appLink = useAppLink;
241
+ const stateSurface = {
242
+ pairUrl: surface.pairUrl,
243
+ qrUrl: surface.qrUrl,
244
+ appLink: useAppLink,
245
+ cancel
246
+ };
247
+ options.onState?.({ status: "pending", expiresIn: started.expires_in, ...stateSurface });
248
+ if (useAppLink && typeof window !== "undefined") {
249
+ window.location.assign(started.pair_url);
250
+ }
251
+ code = await pollUntilApproved(
252
+ issuer,
253
+ started.request_id,
254
+ (s) => options.onState?.({ ...s, ...stateSurface }),
255
+ controller.signal
256
+ );
257
+ }
258
+ if (flow === "auth-code") {
259
+ const response2 = {
260
+ code,
261
+ scope: options.scope ?? "openid",
262
+ app_state: options.app_state,
263
+ code_verifier: verifier,
264
+ nonce
265
+ };
266
+ return response2;
267
+ }
268
+ const tokens = await exchangeCode(issuer, {
269
+ code,
270
+ code_verifier: verifier,
271
+ client_id: options.clientId
272
+ });
273
+ const claims = unsafeClaims(tokens.id_token);
274
+ const response = {
275
+ credential: tokens.id_token,
276
+ clientId: options.clientId,
277
+ select_by: selectBy,
278
+ acr: claims.acr ?? "zoreal.device"
279
+ };
280
+ return response;
281
+ } catch (e) {
282
+ if (e instanceof DOMException && e.name === "AbortError") throw e;
283
+ if (e instanceof OAuthFlowError || e instanceof FlowAbandonedError) throw e;
284
+ throw new FlowAbandonedError({
285
+ type: "unknown",
286
+ description: e instanceof Error ? e.message : String(e)
287
+ });
288
+ }
289
+ };
290
+ const promise = run();
291
+ promise.catch(() => {
292
+ });
293
+ return {
294
+ promise,
295
+ cancel,
296
+ get requestId() {
297
+ return surface.requestId;
298
+ },
299
+ get pairUrl() {
300
+ return surface.pairUrl;
301
+ },
302
+ get qrUrl() {
303
+ return surface.qrUrl;
304
+ },
305
+ get appLink() {
306
+ return surface.appLink;
307
+ }
308
+ };
309
+ }
310
+ // Annotate the CommonJS export names for ESM import in node:
311
+ 0 && (module.exports = {
312
+ DEFAULT_ISSUER,
313
+ FlowAbandonedError,
314
+ OAuthFlowError,
315
+ POLL_INTERVAL_ENROLLING_MS,
316
+ POLL_INTERVAL_MS,
317
+ SDK_NAME,
318
+ SDK_VERSION,
319
+ WIRE_VERSION,
320
+ challengeS256,
321
+ exchangeCode,
322
+ generateState,
323
+ generateVerifier,
324
+ isMobileUserAgent,
325
+ pollUntilApproved,
326
+ startLogin,
327
+ startPairing,
328
+ unsafeClaims
329
+ });
330
+ //# sourceMappingURL=index.cjs.map