@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 +21 -0
- package/README.md +301 -0
- package/dist/index.cjs +330 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +282 -0
- package/dist/index.d.ts +282 -0
- package/dist/index.js +287 -0
- package/dist/index.js.map +1 -0
- package/package.json +45 -0
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
|