@zoreal/oauth2-js 0.1.19 → 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 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
- // On the user's click, never on page load:
88
- const handle = startLogin({
89
- flow: 'auth-code',
90
- clientId: 'ast_your_asset_id',
91
- scope: 'openid email profile.name',
92
- // The QR, its live status, the countdown and the cancel wiring are drawn
93
- // by this package. Nothing to render, nothing to translate.
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
- const { code, code_verifier, nonce } = await handle.promise;
97
- // Send ALL THREE to your backend over TLS. Your backend calls POST /token
98
- // with the code, the verifier and its client authentication, verifies the
99
- // ID token (including the nonce), then reads email and name from /userinfo.
100
- await fetch('/api/auth/zoreal', {
101
- method: 'POST',
102
- headers: { 'Content-Type': 'application/json' },
103
- body: JSON.stringify({ code, code_verifier, nonce }),
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 it
123
- skips the QR and opens the app directly through the pairing link. Either way
124
- your page just awaits `handle.promise`.
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, disable the button, and show it working until the promise settles. 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. |
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 no framework — that
456
- runs the auth-code flow, shows the pairing UI from `onState`, and hands
457
- `{ code, code_verifier, nonce }` to your backend. **Your backend is where the
458
- login is actually verified**: it exchanges the code at `/token` with its client
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 PairingState,
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 panel = document.createElement('div'); // holds the pairing UI
475
- root.append(button, panel);
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 handle.promise;
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 the ZOREAL nonce
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
- return; // the user closed the dialog; say nothing
536
- }
537
- if (e instanceof FlowAbandonedError && e.reason.type === 'request_denied') {
538
- panel.textContent = 'Login declined. Try again when you are ready.';
539
- return; // a human outcome, not an error to alarm on
540
- }
541
- if (e instanceof FlowAbandonedError && e.reason.type === 'request_expired') {
542
- panel.textContent = 'That took too long. Try again.';
543
- return;
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