@zoreal/oauth2-js 0.1.20 → 0.1.21
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 +106 -89
- package/dist/index.cjs +1301 -1199
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -2
- package/dist/index.d.ts +9 -2
- package/dist/index.js +1301 -1199
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -82,26 +82,51 @@ the issuer is `https://id.zoreal.com` in every environment.
|
|
|
82
82
|
## Quick start: auth-code (email and name, needs your backend)
|
|
83
83
|
|
|
84
84
|
```ts
|
|
85
|
-
import { startLogin } from '@zoreal/oauth2-js';
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
85
|
+
import { startLogin, resumeLogin } from '@zoreal/oauth2-js';
|
|
86
|
+
|
|
87
|
+
const button = document.querySelector<HTMLButtonElement>('#zoreal')!;
|
|
88
|
+
|
|
89
|
+
// Everything the flow can end in, one place: success posts the three values
|
|
90
|
+
// to your backend, and every other outcome readies the button again.
|
|
91
|
+
async function finish(promise: Promise<{ code: string; code_verifier: string; nonce: string }>) {
|
|
92
|
+
try {
|
|
93
|
+
const { code, code_verifier, nonce } = await promise;
|
|
94
|
+
// Send ALL THREE to your backend over TLS. Your backend calls POST /token
|
|
95
|
+
// with the code, the verifier and its client authentication, verifies the
|
|
96
|
+
// ID token (including the nonce), then reads email and name from /userinfo.
|
|
97
|
+
await fetch('/api/auth/zoreal', {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'Content-Type': 'application/json' },
|
|
100
|
+
body: JSON.stringify({ code, code_verifier, nonce }),
|
|
101
|
+
});
|
|
102
|
+
} catch (e) {
|
|
103
|
+
// AbortError: the person closed the dialog. FlowAbandonedError: declined or
|
|
104
|
+
// expired. Neither is an error to surface; see the complete example.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
95
107
|
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
108
|
+
// On the user's click, from the click handler itself, never on page load. On
|
|
109
|
+
// a phone this tap is a navigation to the provider, which opens the ZOREAL ID
|
|
110
|
+
// app; nothing may be awaited before startLogin runs. `control` is the button:
|
|
111
|
+
// the package disables it, runs the pairing modal's light round it until the
|
|
112
|
+
// login ends, and lets it go on every outcome. Nothing else to do.
|
|
113
|
+
button.onclick = () => {
|
|
114
|
+
void finish(
|
|
115
|
+
startLogin({
|
|
116
|
+
flow: 'auth-code',
|
|
117
|
+
clientId: 'ast_your_asset_id',
|
|
118
|
+
scope: 'openid email profile.name',
|
|
119
|
+
control: button,
|
|
120
|
+
// The QR, its live status, the countdown and the cancel wiring are drawn
|
|
121
|
+
// by this package. Nothing to render, nothing to translate.
|
|
122
|
+
}).promise
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// On every load of this page: after the approval in the app, the app reopens
|
|
127
|
+
// this page and the sign-in is finished here. null when this load is not one.
|
|
128
|
+
const returned = resumeLogin({ clientId: 'ast_your_asset_id', control: button });
|
|
129
|
+
if (returned) void finish(returned.promise as Promise<{ code: string; code_verifier: string; nonce: string }>);
|
|
105
130
|
```
|
|
106
131
|
|
|
107
132
|
## Quick start: browser-direct (no backend, pseudonymous)
|
|
@@ -119,9 +144,10 @@ const { credential } = await handle.promise;
|
|
|
119
144
|
```
|
|
120
145
|
|
|
121
146
|
On desktop this package opens the [pairing modal](#the-pairing-modal); the user
|
|
122
|
-
scans the QR with their phone and approves in the ZOREAL ID app. On a phone
|
|
123
|
-
|
|
124
|
-
your page
|
|
147
|
+
scans the QR with their phone and approves in the ZOREAL ID app. On a phone the
|
|
148
|
+
tap itself navigates to the provider, which opens the app, and after the
|
|
149
|
+
approval the app reopens your page, where `resumeLogin()` finishes the sign-in.
|
|
150
|
+
Either way your page awaits a promise.
|
|
125
151
|
|
|
126
152
|
## The pairing modal
|
|
127
153
|
|
|
@@ -140,7 +166,7 @@ is nothing to configure and nothing to draw.
|
|
|
140
166
|
|
|
141
167
|
| | |
|
|
142
168
|
| --- | --- |
|
|
143
|
-
| **Mobile** | No QR and no modal. The tap itself is a navigation: `startLogin` sends the tab to the provider's `/pair/start` with the pairing's parameters, synchronously, and the provider answers with a redirect to the pairing's universal link, which the ZOREAL ID app claims while the page stays put and polls. A browser hands a link to an app only inside a navigation the person began, which is why nothing is fetched first. With no app installed the same redirect lands on the page that installs it. Call `startLogin` from the click handler itself
|
|
169
|
+
| **Mobile** | No QR and no modal. The tap itself is a navigation: `startLogin` sends the tab to the provider's `/pair/start` with the pairing's parameters, synchronously, and the provider answers with a redirect to the pairing's universal link, which the ZOREAL ID app claims while the page stays put and polls. A browser hands a link to an app only inside a navigation the person began, which is why nothing is fetched first. With no app installed the same redirect lands on the page that installs it. Call `startLogin` from the click handler itself and pass the button as `control`: the package disables it, runs the pairing modal's light round it until the login ends, and lets it go on every outcome. Once the holder has approved, the app reopens your page with the pairing named in the fragment; call `resumeLogin({ clientId })` on every page load where `startLogin` can be called, and it finishes the sign-in there, resolving the way `startLogin` would have, or answers `null` at once when the page load is not a return. The tab that was left behind keeps polling and stands down when the returned page finishes first. Force one or the other with `display: 'qr'` / `'link'`. The choice is made before the pairing is created, because the provider binds the surface there: a link-mode pairing is claimed only by the app that opened that exact link, and has no QR at all. |
|
|
144
170
|
| **Live status** | Copy and title follow the pairing: waiting for a scan, then waiting for approval once the code is claimed (the spent QR blurs out behind a phone glyph). |
|
|
145
171
|
| **Title** | Says what the scan is for, inferred from the request: "Scan to sign in" for `openid`, `email` and `profile.name`; "Scan to verify your identity" once a document attribute such as `zoreal.age` or `profile.birthdate` is requested; "Scan to prove you are a real human" for `openid` alone with `acr_values: 'zoreal.live'`. Override with `intent`, one of `'sign-in'`, `'identify'`, `'presence'`, when the scope does not say. |
|
|
146
172
|
| **Countdown** | Counts down to expiry, turning amber under 20s. Reads the clock each tick rather than decrementing, so a backgrounded tab comes back honest. |
|
|
@@ -452,10 +478,13 @@ error is an `OAuthFlowError`, or the rare `FlowAbandonedError` of type `unknown`
|
|
|
452
478
|
|
|
453
479
|
## A complete example
|
|
454
480
|
|
|
455
|
-
A whole "Continue with ZOREAL" control in plain TypeScript
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
481
|
+
A whole "Continue with ZOREAL" control in plain TypeScript, no framework, the
|
|
482
|
+
shape a production auth-code integration takes: the button, busy from the tap
|
|
483
|
+
until the flow ends, this package's pairing modal on a computer and the app
|
|
484
|
+
hand-over on a phone, `{ code, code_verifier, nonce }` to your backend, the
|
|
485
|
+
human outcomes treated as the non-events they are, and the return from the app
|
|
486
|
+
on a phone finished by `resumeLogin` on load. **Your backend is where the login
|
|
487
|
+
is actually verified**: it exchanges the code at `/token` with its client
|
|
459
488
|
authentication, checks the ID token's signature, `iss`, `aud`, `exp` and
|
|
460
489
|
`nonce` against the JWKS, and reads `/userinfo`. Nothing the browser resolves is
|
|
461
490
|
trusted until it has.
|
|
@@ -463,63 +492,27 @@ trusted until it has.
|
|
|
463
492
|
```ts
|
|
464
493
|
import {
|
|
465
494
|
startLogin,
|
|
495
|
+
resumeLogin,
|
|
466
496
|
OAuthFlowError,
|
|
467
497
|
FlowAbandonedError,
|
|
468
|
-
type
|
|
498
|
+
type ZorealCodeResponse,
|
|
469
499
|
} from '@zoreal/oauth2-js';
|
|
470
500
|
|
|
501
|
+
const CLIENT_ID = 'ast_your_asset_id';
|
|
502
|
+
|
|
471
503
|
export function mountZorealButton(root: HTMLElement) {
|
|
472
504
|
const button = document.createElement('button');
|
|
505
|
+
button.type = 'button';
|
|
473
506
|
button.textContent = 'Continue with ZOREAL';
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
let handle: ReturnType<typeof startLogin> | null = null;
|
|
478
|
-
|
|
479
|
-
const renderPairing = (s: PairingState) => {
|
|
480
|
-
panel.replaceChildren();
|
|
481
|
-
if (s.appLink) {
|
|
482
|
-
panel.textContent = 'Opening the ZOREAL ID app…';
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
|
-
if (s.qrUrl) {
|
|
486
|
-
const img = document.createElement('img');
|
|
487
|
-
// Provider-served, and a new frame every few seconds: never draw your
|
|
488
|
-
// own QR of pairUrl, and never keep the first URL. A real UI keeps one
|
|
489
|
-
// <img> and assigns src, rather than rebuilding it as this sketch does.
|
|
490
|
-
img.src = s.qrUrl;
|
|
491
|
-
img.alt = 'Scan with the ZOREAL ID app';
|
|
492
|
-
panel.append(img);
|
|
493
|
-
}
|
|
494
|
-
const status = document.createElement('p');
|
|
495
|
-
status.textContent = s.status; // pending | claimed | approved | ...
|
|
496
|
-
panel.append(status);
|
|
497
|
-
if (s.cancel) {
|
|
498
|
-
const cancel = document.createElement('button');
|
|
499
|
-
cancel.textContent = 'Cancel';
|
|
500
|
-
cancel.onclick = () => s.cancel!();
|
|
501
|
-
panel.append(cancel);
|
|
502
|
-
}
|
|
503
|
-
};
|
|
504
|
-
|
|
505
|
-
button.onclick = async () => {
|
|
506
|
-
handle?.cancel(); // one flow at a time
|
|
507
|
-
handle = startLogin({
|
|
508
|
-
flow: 'auth-code',
|
|
509
|
-
clientId: 'ast_your_asset_id',
|
|
510
|
-
scope: 'openid email profile.name',
|
|
511
|
-
// This example draws its own panel, so it opts out of the built-in
|
|
512
|
-
// modal. Drop these two lines and delete renderPairing to use it.
|
|
513
|
-
pairingUI: 'none',
|
|
514
|
-
onState: renderPairing,
|
|
515
|
-
});
|
|
507
|
+
const note = document.createElement('p');
|
|
508
|
+
note.setAttribute('role', 'status');
|
|
509
|
+
root.append(button, note);
|
|
516
510
|
|
|
511
|
+
const finish = async (promise: Promise<ZorealCodeResponse>) => {
|
|
517
512
|
try {
|
|
518
|
-
const { code, code_verifier, nonce } = await
|
|
519
|
-
panel.replaceChildren();
|
|
520
|
-
|
|
513
|
+
const { code, code_verifier, nonce } = await promise;
|
|
521
514
|
// Post all three to YOUR backend over TLS. Protect this route with your
|
|
522
|
-
// framework's normal CSRF / same-origin controls
|
|
515
|
+
// framework's normal CSRF / same-origin controls: the ZOREAL nonce
|
|
523
516
|
// protects the token, not your endpoint. The backend verifies before it
|
|
524
517
|
// trusts, then establishes the session.
|
|
525
518
|
const res = await fetch('/api/auth/zoreal', {
|
|
@@ -530,28 +523,52 @@ export function mountZorealButton(root: HTMLElement) {
|
|
|
530
523
|
if (!res.ok) throw new Error('backend rejected the login');
|
|
531
524
|
window.location.assign('/dashboard');
|
|
532
525
|
} catch (e) {
|
|
533
|
-
panel.replaceChildren();
|
|
534
526
|
if (e instanceof DOMException && e.name === 'AbortError') {
|
|
535
|
-
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
527
|
+
note.textContent = ''; // the person closed the dialog; say nothing
|
|
528
|
+
} else if (e instanceof FlowAbandonedError && e.reason.type === 'request_denied') {
|
|
529
|
+
note.textContent = 'Login declined. Try again when you are ready.'; // a human outcome
|
|
530
|
+
} else if (e instanceof FlowAbandonedError && e.reason.type === 'request_expired') {
|
|
531
|
+
note.textContent = 'That took too long. Try again.';
|
|
532
|
+
} else if (e instanceof OAuthFlowError) {
|
|
533
|
+
note.textContent = e.description ?? e.error; // provider's words, verbatim
|
|
534
|
+
} else {
|
|
535
|
+
note.textContent = 'Something went wrong. Try again.';
|
|
544
536
|
}
|
|
545
|
-
if (e instanceof OAuthFlowError) {
|
|
546
|
-
panel.textContent = e.description ?? e.error; // provider's words, verbatim
|
|
547
|
-
return;
|
|
548
|
-
}
|
|
549
|
-
panel.textContent = 'Something went wrong. Try again.';
|
|
550
537
|
}
|
|
551
538
|
};
|
|
539
|
+
|
|
540
|
+
let handle: ReturnType<typeof startLogin> | null = null;
|
|
541
|
+
|
|
542
|
+
// From the click handler itself, with nothing awaited first: on a phone this
|
|
543
|
+
// tap is a navigation to the provider, which opens the ZOREAL ID app. On a
|
|
544
|
+
// computer this package draws the pairing modal; nothing here renders a QR.
|
|
545
|
+
// `control` is the button: the package disables it, runs the pairing
|
|
546
|
+
// modal's light round it until the login ends, and lets it go on every
|
|
547
|
+
// outcome.
|
|
548
|
+
button.onclick = () => {
|
|
549
|
+
handle?.cancel(); // one flow at a time
|
|
550
|
+
note.textContent = '';
|
|
551
|
+
handle = startLogin({
|
|
552
|
+
flow: 'auth-code',
|
|
553
|
+
clientId: CLIENT_ID,
|
|
554
|
+
scope: 'openid email profile.name',
|
|
555
|
+
control: button,
|
|
556
|
+
});
|
|
557
|
+
void finish(handle.promise);
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
// The return. After the approval in the ZOREAL ID app on a phone, the app
|
|
561
|
+
// reopens this page with the pairing named in the fragment; the sign-in is
|
|
562
|
+
// finished here. null at once when this page load is not a return.
|
|
563
|
+
const returned = resumeLogin({ clientId: CLIENT_ID, control: button });
|
|
564
|
+
if (returned) void finish(returned.promise as Promise<ZorealCodeResponse>);
|
|
552
565
|
}
|
|
553
566
|
```
|
|
554
567
|
|
|
568
|
+
To draw the pairing UI yourself instead of using the modal, pass
|
|
569
|
+
`pairingUI: 'none'` and render from `onState`; see
|
|
570
|
+
[Rendering it yourself](#rendering-it-yourself).
|
|
571
|
+
|
|
555
572
|
For the no-backend case, swap `flow: 'auth-code'` for the default browser-direct
|
|
556
573
|
flow: `handle.promise` then resolves `{ credential }`, an ID token carrying only
|
|
557
574
|
`sub` and the proof of verification. It **still** has to be verified server-side
|