Login with ZOREAL for the browser, framework-free: a ZOREAL Verified Proof-of-Human behind every sign-in.
This is the wire core of
@zoreal/oauth2-react
without the React: the pairing (QR or app link), the polling, PKCE, and the
browser-side code exchange, exposed as one imperative call. Use it directly
from plain JavaScript, or build a Vue, Svelte, or Angular wrapper on it; the
React package is what that wrapper looks like when it is finished.
@zoreal/oauth2-js (this package) the flow: pairing, polling, PKCE, exchange
your wrapper or plain JS the UI: render what onState carries
npm install @zoreal/oauth2-jsZero runtime dependencies. ESM and CJS. Browser APIs only (fetch,
crypto.subtle); any evergreen browser has everything it needs.
clientId is the only credential this package needs, and it comes from a ZOREAL
asset.
- Create an account at https://zoreal.com and open Assets.
- Create an asset — a website (a domain you own) or an app bundle (a
reverse-DNS bundle id). An asset is the thing users log in to; its token is
your
clientIdand it looks likeast_.... - On the asset, open the OAuth2 tab and set:
- the JavaScript origins this page is served from and the redirect URIs your app uses — requests from anything not registered are rejected, which is the core control,
- the scopes the client may request (see the catalogue below); a request for a scope not on the list is refused at the pairing step,
- client authentication — for the auth-code flow, generate a client secret or register a JWKS on the asset. That credential lives on your backend and never comes here. Browser-direct is a public client: PKCE alone, no secret.
- A website asset must verify its domain (a DNS or meta-tag proof, shown in
the dashboard) before it can request personal-data scopes or sign users in;
the verified domain is what your users'
subis pairwise against.
clientId is public by design — it ships in your frontend, and this package
takes nothing else. No client secret has a home in the browser (see No secret
has a home here, below).
ZOREAL never issues fake or sandbox humans: a pool of test identities would be a fraud vector against the exact thing the product proves. So you always authenticate real ZOREAL IDs.
To develop and test, create a free ZOREAL ID for yourself (enrol in the
ZOREAL ID app) and sign in with it. Mark your asset's environment sandbox in
the dashboard while building — a sandbox asset may register http://localhost
origins and redirect URIs that a production asset may not — and flip it to
production when you ship. The identities are real either way; only the allowed
origins differ. There is no mock provider and no hosted test issuer to point at:
the issuer is https://id.zoreal.com in every environment.
- You have a backend and want the user's email or name (most apps): use
flow: 'auth-code'. Your backend gets the email, name, and verification details from/userinfo. Start here. - You have no backend and only need to know "this is a verified, unique human, and the same one as last time": use the default browser-direct flow. It returns a stable per-user identifier and proof of verification, but no email or name. Email and other personal details are never placed in a browser-side token; that is what the auth-code flow and your backend are for.
import { startLogin, resumeLogin } from '@zoreal/oauth2-js';
const button = document.querySelector<HTMLButtonElement>('#zoreal')!;
// Everything the flow can end in, one place: success posts the three values
// to your backend, and every other outcome readies the button again.
async function finish(promise: Promise<{ code: string; code_verifier: string; nonce: string }>) {
try {
const { code, code_verifier, nonce } = await promise;
// Send ALL THREE to your backend over TLS. Your backend calls POST /token
// with the code, the verifier and its client authentication, verifies the
// ID token (including the nonce), then reads email and name from /userinfo.
await fetch('/api/auth/zoreal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, code_verifier, nonce }),
});
} catch (e) {
// AbortError: the person closed the dialog. FlowAbandonedError: declined or
// expired. Neither is an error to surface; see the complete example.
}
}
// On the user's click, from the click handler itself, never on page load. On
// a phone this tap is a navigation to the provider, which opens the ZOREAL ID
// app; nothing may be awaited before startLogin runs. `control` is the button:
// the package disables it, runs the pairing modal's light round it until the
// login ends, and lets it go on every outcome. Nothing else to do.
button.onclick = () => {
void finish(
startLogin({
flow: 'auth-code',
clientId: 'ast_your_asset_id',
scope: 'openid email profile.name',
control: button,
// The QR, its live status, the countdown and the cancel wiring are drawn
// by this package. Nothing to render, nothing to translate.
}).promise
);
};
// On every load of this page: after the approval in the app, the app reopens
// this page and the sign-in is finished here. null when this load is not one.
const returned = resumeLogin({ clientId: 'ast_your_asset_id', control: button });
if (returned) void finish(returned.promise as Promise<{ code: string; code_verifier: string; nonce: string }>);import { startLogin } from '@zoreal/oauth2-js';
const handle = startLogin({ clientId: 'ast_your_asset_id' });
const { credential } = await handle.promise;
// `credential` is an ID token carrying a stable per-user identifier (`sub`)
// and proof the person is a verified, unique human. No email, no name: use
// the auth-code flow above for those. Verify it on your server against the
// JWKS before trusting it.On desktop this package opens the pairing modal; the user
scans the QR with their phone and approves in the ZOREAL ID app. On a phone the
tap itself navigates to the provider, which opens the app, and after the
approval the app reopens your page, where resumeLogin() finishes the sign-in.
Either way your page awaits a promise.
On desktop a QR sign-in cannot complete unless something puts the pairing code
on screen, so this package does it. startLogin mounts a dialog, keeps it in
step with the pairing, and takes it down when the flow settles. You render
nothing.
The code on screen changes every few seconds. Each image is one frame of the pairing, and the provider refuses a frame the sequence has moved past, so a screenshot of the code passed to someone else is already dead when it arrives: signing in needs the screen as it is right now. The provider renders the frames and this package re-fetches the image on the interval the provider gives it, preloading the next one so the code never flickers to blank. There is nothing to configure and nothing to draw.
| 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. |
| 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). |
| 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. |
| Countdown | Counts down to expiry, turning amber under 20s. Reads the clock each tick rather than decrementing, so a backgrounded tab comes back honest. |
| Timeout | Closes and cancels at zero. Follows the provider's expiry (five minutes); pairingTimeoutMs can shorten it, never extend it. |
| Cancel | The X, the Cancel button, Escape, clicking outside and the timeout are one behaviour: abort the poll, close, reject the promise with AbortError. |
| No ZOREAL ID yet | A footer says the same code also installs the app. Without it the panel reads as "scan this with something I do not have", and the flow dead-ends at the one moment it can still be recovered. |
| Themes | theme: 'auto' (default) follows prefers-color-scheme; 'light' and 'dark' force it. In dark mode the code is drawn light on the dark surface, the mark included, and the light around it runs brighter and wider. |
| Language | Ships its own copy in 39 locales. Pass locale and the modal matches the page it opened on; omit it and it follows the browser's preference list, English as the floor. Arabic, Hebrew and Urdu flip the dialog to RTL. |
| Accessibility | role="dialog", aria-modal, labelled by its title, focus moved in on open, scroll locked, visible focus rings, and a prefers-reduced-motion fallback. |
startLogin({
clientId: 'ast_your_asset_id',
locale: 'sv', // omit to follow the browser
theme: 'auto', // 'light' | 'dark'
pairingTimeoutMs: 120_000, // shorten the provider's five minutes; omit to keep them
});Styling is a single <style> tag injected once, every selector prefixed zrl-
and scoped to the dialog. There is no CSS file to import and nothing to add to
your build, because a required import step is a required support ticket. The
DOM is built with createElement, never innerHTML: this renders on someone
else's sign-in page.
Arabic · Bengali · Bosnian · Bulgarian · Chinese (Simplified) · Chinese (Traditional) · Croatian · Czech · Danish · Dutch · English · Filipino/Tagalog · Finnish · French · German · Greek · Hebrew · Hindi · Hungarian · Indonesian · Italian · Japanese · Korean · Malay · Norwegian · Polish · Portuguese · Portuguese (Brazil) · Romanian · Russian · Serbian · Spanish · Spanish (Latin America) · Swedish · Thai · Turkish · Ukrainian · Urdu · Vietnamese
Resolution handles the cases that usually get missed: zh splits by script rather
than region, es-MX and the other Latin American regions resolve to Latin American
Spanish instead of peninsular, pt-BR stays out of European Portuguese, and the
superseded codes (iw, in) plus nb/nn and fil reach the right table.
Framework wrappers and anyone with their own design system opt out:
startLogin({ clientId: 'ast_your_asset_id', pairingUI: 'none' });onState then carries everything you need on every state: qrUrl, pairUrl,
status, expiresIn and cancel. Render qrUrl in an <img>; do not draw
your own. mountPairingModal is also exported if you want the real dialog but
driven on your own terms.
Render the qrUrl from the state you are given, every time, and never cache
the first one: it is a moving code, a fresh frame arrives every
qrRefreshSeconds, and a UI holding the first URL shows one the provider has
already refused.
startLogin returns synchronously with everything a UI needs to drive the
flow:
| Field | What it is |
|---|---|
promise |
resolves with the mode's result; rejects with OAuthFlowError, FlowAbandonedError, or an AbortError after cancel() |
cancel() |
abandons the flow: stops the poll, rejects the promise |
requestId |
the pairing request id, once the provider has created it |
pairUrl |
the pairing link, once created. On an app-link flow it carries the start token; navigate to it verbatim |
qrUrl |
the provider-served SVG of the current code. Put it in an <img>; do not draw your own. It changes while the pairing is pending, so read it from onState rather than here |
appLink |
true when the flow resolved to the app link (mobile) rather than a QR |
requestId, pairUrl, qrUrl and appLink are undefined until the
pairing request exists (one round-trip), and stay undefined when
prompt: 'none' resolves silently. The same four values also arrive on every
onState callback, which is the reliable place to render from.
onState receives a PairingState on every change, and on every QR frame:
status (pending | claimed | approved | denied | expired | enrolling),
expiresIn, enrolmentDeadline, pairUrl, qrUrl, qrRefreshSeconds,
appLink, and cancel.
Browser-direct:
{ credential: string, // the ID token; verify server-side against the JWKS
clientId: string,
select_by: 'qr' | 'app_link' | 'device' | 'session',
acr: 'zoreal.live' | 'zoreal.device' | 'zoreal.session' }Auth-code:
{ code: string, // single-use, short-lived
code_verifier: string, // PKCE; your backend needs it to complete the exchange
nonce: string, // your backend checks it against the ID token's nonce claim
scope: string,
app_state?: string } // whatever you passed in, echoed backux_mode: 'redirect' is not supported: it would put the PKCE verifier in a
URL, which is a credential in every access log on the path. startLogin
throws rather than doing that.
Scopes are the scope string you pass to startLogin (always starting with
openid), consented to by the holder, and pre-authorized on your asset. What
each grants and where it is delivered:
| Scope | Claims | Delivered in | Tier | Requires |
|---|---|---|---|---|
openid |
sub, iss, aud, exp, iat, nonce, auth_time, acr, amr, and the assurance block |
ID token | A | any client |
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 |
zoreal.nationality |
nationality (ISO 3166-1 alpha-3) |
ID token | A | any client |
email |
email, email_verified |
/userinfo |
B | confidential client + verified domain |
profile.name |
name, given_name, family_name |
/userinfo |
B | confidential client + verified domain |
profile.birthdate |
birthdate (full ISO 8601 date) |
/userinfo |
B | confidential client + verified domain |
profile.document |
document_type, document_number, issuing_country, document_expires_on |
/userinfo |
B | confidential client + verified domain |
profile.portrait |
portrait (the chip's facial image; GDPR Article 9 data) |
/userinfo |
C | confidential client + verified domain — registrable but not served yet |
- Tier A rides in the ID token and is available to every client, so the
browser-direct flow can use it with no backend at all. Tier B and C are
personal data, served only from
/userinfoto a confidential client on a domain you have verified, and never placed in a browser token — which is why any scope beyond Tier A needsflow: 'auth-code'and your backend. A public client that asks for one is refused at the pairing step withinvalid_scope. - Age thresholds are a fixed set — 13, 16, 18, 21, 65 — that you register on
the asset. The
age_over_*claim for a threshold you did not register is simply absent (no claim was minted), which a backend reads asnull/nilrather thanfalse.
acr is an OpenID Connect standard claim — Authentication Context Class
Reference. It is a string in the ID token that says how strongly this login
was authenticated. sub tells you who (a stable, pairwise identifier for
this person at your site); acr tells you how sure ZOREAL is that the person is
really there for this login. A stolen, unlocked phone can still produce a sub;
it cannot produce a fresh zoreal.live.
This core is the request side of acr: you ask for a level via
startLogin, which decides what the holder's ZOREAL ID app makes them do.
Whether it was reached is decided by the signed token and checked on your
backend.
Weakest to strongest. acr reports what actually happened, never what was asked.
acr |
What the holder did | amr |
Proves | Does not prove |
|---|---|---|---|---|
zoreal.session |
Nothing — a returning holder resumed silently from an existing ZOREAL session, no phone interaction | [] |
Continuity | Presence |
zoreal.device |
Approved on their enrolled phone: a secure-element key signature released by a local biometric/passcode unlock | ["hwk","user"] |
Possession of the enrolled device and a local unlock | That a live face was captured for this login |
zoreal.live |
The above plus a fresh face capture this login — a flash-plus-zoom video scored for presentation attacks and screen replay, matched 1:1 to the government document read at enrolment | ["hwk","face","user"] |
A live, real, unique human, verified to be the enrolled person, at the moment of this login | — (strongest) |
amr (Authentication Methods References) lists the factors: hwk a hardware
key, user a presence/unlock gesture, face a face biometric. zoreal.live is
zoreal.device with face added. The default is zoreal.device.
zoreal.device(the default): a forum, a community, a normal login. Pass noacr_values.zoreal.live: a bank onboarding, a high-value transaction, an age-gated purchase, a first login, a "confirm it is really you" step.zoreal.sessionis never requested; it is the silent convenience re-auth (prompt: 'none') a returning holder gets at a consented site.
acr_values is an option on startLogin, typed AcrValue | AcrValue[] where
AcrValue = 'zoreal.live' | 'zoreal.device' | 'zoreal.session'.
const handle = startLogin({
clientId: 'ast_your_asset_id',
acr_values: 'zoreal.live', // the app now makes the holder pass a face capture
});In browser-direct mode the resolved level is on the credential response as
acr, parsed from the ID token; the token stays the authority.
acr_values here is advisory: it shapes what the holder is asked to do, and
proves nothing on its own, because a browser is attacker-controlled. The proof is
the signed acr claim, minted by ZOREAL, verified on your backend — the
ZOREAL backend libraries (zoreal-oauth2 for Ruby and its siblings for Node,
Python, PHP, Go, JVM and .NET) take a required-acr argument at exchange and
refuse a token below the level. A relying party that requests zoreal.live but
never verifies the claim has checked nothing.
acr grades this login event. The assurance block in the token (uniqueness
basis, verification month, chip-liveness, trust tier, key protection) describes
the identity behind it. One is about now; the other about who they are. A
high-value flow wants both.
The ID token carries a zoreal claim — the assurance block — describing the
strength of the identity behind this login, distinct from acr, which grades
the login event. In browser-direct mode you can read it for display with
unsafeClaims(credential).zoreal (convenience only — the token is the authority
once your backend has verified it); in the auth-code flow your backend reads it
from the verified token. Its keys and their value sets:
| Key | Values | Meaning |
|---|---|---|
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 |
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 |
chip_liveness_proven |
true | false |
Whether the passport chip's active-authentication challenge was proven (a genuine chip, not a clone) |
trust_tier |
high | standard |
high when chip_liveness_proven, else standard |
key_protection |
secure_enclave | strongbox | tee | software |
How the holder's device key is protected. software means no hardware attestation |
A high-value flow usually pairs acr_values: 'zoreal.live' (fresh presence)
with a check on the assurance block (identity strength) — e.g. requiring
uniqueness === 'personal_number' and trust_tier === 'high'. Both checks are
enforced where enforcement counts: on your backend, against the verified token.
| Export | What it does |
|---|---|
startLogin(options) |
the whole flow: pairing, polling, and (browser-direct) the exchange. Returns the handle above |
startPairing(issuer, params) |
POST {issuer}/pair, returns { request_id, pair_url, expires_in } or an immediate { code } |
pollUntilApproved(issuer, requestId, onState?, signal?) |
polls /pair/:id/status at the fixed cadence until a code or a terminal state |
exchangeCode(issuer, { code, code_verifier, client_id }) |
POST {issuer}/token: public client, PKCE, no secret |
generateVerifier() / challengeS256(v) / generateState() |
PKCE and state material, S256 only |
unsafeClaims(idToken) |
reads claims without verifying. Convenience only; verification happens server-side |
isMobileUserAgent() |
whether this user agent gets the app link rather than a QR |
mountPairingModal(state, { onCancel, locale?, theme?, timeoutMs? }) |
mounts the dialog yourself, for pairingUI: 'none' callers who still want the real one. Returns { update, close }, or null outside a browser |
DEFAULT_PAIRING_TIMEOUT_MS |
300000, the modal's cap when the provider states no expiry |
DEFAULT_QR_REFRESH_SECONDS |
3, how often the QR frame is re-fetched when the provider does not say |
Errors: OAuthFlowError (the provider refused; error is the OAuth code,
description is the provider's reason verbatim) and FlowAbandonedError (a
human outcome: reason.type is request_denied, request_expired,
enrolment_abandoned, or unknown for failures that never reached the
provider). cancel() rejects with a DOMException named AbortError.
startLogin options controlling the built-in modal: pairingUI
('modal' default, 'none' to render your own), theme ('auto' default,
'light', 'dark'), pairingTimeoutMs (the provider's expiry by default), and locale, which
is sent to the provider AND picks the modal's own language. See
The pairing modal.
All types are exported: PairingState, ZorealCredentialResponse,
ZorealCodeResponse, StartLoginOptions, BrowserDirectLoginOptions,
AuthCodeLoginOptions, LoginHandle, ErrorCode, NonOAuthError,
SelectBy, AcrValue, PairingUI, PairDisplay, ZorealTheme,
PairingModalHandle, PairingModalOptions, and the wire shapes.
The code exchange can fail with these OAuth codes. In browser-direct mode
this package makes the /token call for you (exchangeCode), and a failure
arrives as an OAuthFlowError whose error is one of these. In auth-code
mode the /token call is your backend's, and it sees the same codes there.
error |
Cause | Retryable? |
|---|---|---|
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 |
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 |
unsupported_grant_type |
Something other than authorization_code reached /token |
No. A bug |
These come back from the pairing step, before any code exists, and are what your UI handles directly:
| Where | Code / reason | This package | Meaning |
|---|---|---|---|
/pair |
invalid_scope |
OAuthFlowError |
A scope not on the asset's allowed list, or a Tier B scope from a public client |
/pair |
invalid_request |
OAuthFlowError |
Missing PKCE/nonce, an unverified sector, an unregistered redirect_uri, or an unknown acr_values |
/pair |
login_required |
OAuthFlowError |
prompt: 'none' with no silent session to resume — the expected quiet outcome, not a failure |
| pairing | request_denied |
FlowAbandonedError |
The holder declined in their ZOREAL ID app — not an error to alarm on; offer to try again |
| pairing | request_expired |
FlowAbandonedError |
The pairing window elapsed, or a required liveness the device could not meet — offer to try again |
OAuthFlowError— the provider refused.erroris the OAuth code (anErrorCode), anddescriptionis the provider's own reason string. Renderdescriptionverbatim; it is the only signal that tells an integrator what to fix (a refused package version arrives this way too).FlowAbandonedError— a human outcome, or a failure that never reached the provider.reason.typeisrequest_denied,request_expired,enrolment_abandoned, orunknown, andreason.descriptioncarries the provider's words when there are any.request_deniedandrequest_expiredare the everyday cancel/timeout paths — treat them as "offer to try again", not as faults to log at error level.AbortError— aDOMExceptionnamedAbortError, thrown when you callhandle.cancel()(or thecancel()on aPairingState). It means the flow was abandoned on purpose; checke.name === 'AbortError'and stay silent.
The two paths that are not failures are a user closing the dialog
(AbortError) and a holder declining (FlowAbandonedError with
request_denied). Everything a real integration should surface to the user as an
error is an OAuthFlowError, or the rare FlowAbandonedError of type unknown.
A whole "Continue with ZOREAL" control in plain TypeScript, no framework, the
shape a production auth-code integration takes: the button, busy from the tap
until the flow ends, this package's pairing modal on a computer and the app
hand-over on a phone, { code, code_verifier, nonce } to your backend, the
human outcomes treated as the non-events they are, and the return from the app
on a phone finished by resumeLogin on load. Your backend is where the login
is actually verified: it exchanges the code at /token with its client
authentication, checks the ID token's signature, iss, aud, exp and
nonce against the JWKS, and reads /userinfo. Nothing the browser resolves is
trusted until it has.
import {
startLogin,
resumeLogin,
OAuthFlowError,
FlowAbandonedError,
type ZorealCodeResponse,
} from '@zoreal/oauth2-js';
const CLIENT_ID = 'ast_your_asset_id';
export function mountZorealButton(root: HTMLElement) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Continue with ZOREAL';
const note = document.createElement('p');
note.setAttribute('role', 'status');
root.append(button, note);
const finish = async (promise: Promise<ZorealCodeResponse>) => {
try {
const { code, code_verifier, nonce } = await promise;
// Post all three to YOUR backend over TLS. Protect this route with your
// framework's normal CSRF / same-origin controls: the ZOREAL nonce
// protects the token, not your endpoint. The backend verifies before it
// trusts, then establishes the session.
const res = await fetch('/api/auth/zoreal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, code_verifier, nonce }),
});
if (!res.ok) throw new Error('backend rejected the login');
window.location.assign('/dashboard');
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
note.textContent = ''; // the person closed the dialog; say nothing
} else if (e instanceof FlowAbandonedError && e.reason.type === 'request_denied') {
note.textContent = 'Login declined. Try again when you are ready.'; // a human outcome
} else if (e instanceof FlowAbandonedError && e.reason.type === 'request_expired') {
note.textContent = 'That took too long. Try again.';
} else if (e instanceof OAuthFlowError) {
note.textContent = e.description ?? e.error; // provider's words, verbatim
} else {
note.textContent = 'Something went wrong. Try again.';
}
}
};
let handle: ReturnType<typeof startLogin> | null = null;
// From the click handler itself, with nothing awaited first: on a phone this
// tap is a navigation to the provider, which opens the ZOREAL ID app. On a
// computer this package draws the pairing modal; nothing here renders a QR.
// `control` is the button: the package disables it, runs the pairing
// modal's light round it until the login ends, and lets it go on every
// outcome.
button.onclick = () => {
handle?.cancel(); // one flow at a time
note.textContent = '';
handle = startLogin({
flow: 'auth-code',
clientId: CLIENT_ID,
scope: 'openid email profile.name',
control: button,
});
void finish(handle.promise);
};
// The return. After the approval in the ZOREAL ID app on a phone, the app
// reopens this page with the pairing named in the fragment; the sign-in is
// finished here. null at once when this page load is not a return.
const returned = resumeLogin({ clientId: CLIENT_ID, control: button });
if (returned) void finish(returned.promise as Promise<ZorealCodeResponse>);
}To draw the pairing UI yourself instead of using the modal, pass
pairingUI: 'none' and render from onState; see
Rendering it yourself.
For the no-backend case, swap flow: 'auth-code' for the default browser-direct
flow: handle.promise then resolves { credential }, an ID token carrying only
sub and the proof of verification. It still has to be verified server-side
against the JWKS before you trust it — a token minted for someone else looks
identical in the browser.
A wrapper owns exactly two things: calling startLogin on the user's
gesture, and rendering what onState carries. Everything else - PKCE, state,
nonce, poll cadence, cancellation - is this package's job.
Decide first whether you are rendering the pairing UI at all. If the built-in
modal is what you want, drop onState and there is nothing to build. If you
are drawing your own, pass pairingUI: 'none' or your users get two QRs on
screen. The example below draws its own, so it opts out.
A minimal Vue 3 composable:
// useZorealLogin.ts
import { onUnmounted, ref } from 'vue';
import {
startLogin,
type PairingState,
type ZorealCredentialResponse,
} from '@zoreal/oauth2-js';
export function useZorealLogin(clientId: string) {
const pairing = ref<PairingState | null>(null);
const credential = ref<ZorealCredentialResponse | null>(null);
const error = ref<string | null>(null);
let active: { cancel: () => void } | null = null;
const login = () => {
active?.cancel();
const handle = startLogin({
clientId,
pairingUI: 'none', // this wrapper renders its own
onState: (s) => (pairing.value = s),
});
active = handle;
handle.promise
.then((r) => (credential.value = r))
.catch((e) => {
if (e?.name !== 'AbortError') error.value = e.message;
})
.finally(() => (pairing.value = null));
};
// A component unmounting mid-login must stop the poll: the provider
// cancels over-polled requests, and an orphaned poll is how one happens.
onUnmounted(() => active?.cancel());
return { login, pairing, credential, error };
}And the template renders the state:
<template>
<button @click="login">Continue with ZOREAL</button>
<div v-if="pairing">
<img v-if="!pairing.appLink" :src="pairing.qrUrl" alt="Scan with the ZOREAL ID app" />
<p>{{ pairing.status }}</p>
<button @click="pairing.cancel">Cancel</button>
</div>
</template>The same shape ports to Svelte (a store fed by onState) or Angular (a
service exposing an observable). The rules a wrapper must keep:
- Render
qrUrlin an<img>; never draw your own QR ofpairUrl. Re-render it on every state: the code moves, and the last frame you were given is the only one the provider still accepts. - Call
cancel()on unmount or navigation. Do not add your own retry loop: the poll cadence is fixed because over-polling cancels the request server-side. - Show
descriptionfrom errors verbatim. It is the provider's own reason, and rewriting it hides the only signal telling an integrator what happened.
The package loads no third-party script, no stylesheet, no font, and has zero runtime dependencies. Two things touch the network, both on the ZOREAL origin:
| CSP directive | Value | Why |
|---|---|---|
connect-src |
https://id.zoreal.com |
starting the pairing, polling it, and (browser-direct) the code exchange |
img-src |
https://id.zoreal.com |
the QR image, served by the provider so it stays correct and current |
- The ID token never carries personal data.
sub, timing,acr/amr, the assurance block, and - if registered -age_over_*booleans andnationality. Email, names, birthdate and document fields come only from/userinfo, read by your backend in the auth-code flow. - The access token lives 10 minutes. Your backend should read
/userinfowhile handling the login, not store the token for later. subis pairwise per verified domain. It is the right account key and it is derived from your registered sector: changing your asset's domain rotates everysubyou have stored. Plan domain changes as a migration.- ES256 only. The provider signs ID tokens with nothing else, and your backend should refuse other algorithms rather than negotiating.
- Always hand the nonce to your backend. This package generates it and resolves it alongside the code; without it your backend cannot tell a substituted ID token from the real one.
- Email is a deliberate choice. It is a Tier B scope precisely because a
shared email defeats the unlinkability the pairwise
subprovides. Request it because you need it, not because the checkbox is familiar. - Sandbox clients accept localhost origins; production clients do not. Registration lives in the ZOREAL dashboard on the asset's OAuth2 tab; Tier B scopes (email, profile.*) need a confidential client on a verified domain, and a public client requesting them is refused at the pairing step.
- No secret has a home here.
startLogintakes no client secret and never will. Browser-direct mode is a public client with PKCE; auth-code mode leaves client authentication to your backend, where the secret lives. - The poll cadence is not a suggestion. 2000ms while pending, 5000ms while enrolling. The provider cancels an over-polling request rather than throttling it, so polling faster kills the login it is trying to save.
Three things this package leans on, and where each stops:
- The nonce binds the token to this login — it is not your CSRF token. This
package generates a nonce, sends it with the pairing request, and resolves it
to you alongside the code. Handing it to your backend lets the backend confirm
the ID token was minted for this login rather than substituted. It does
not protect your own login route: guard
/api/auth/zoreal(or wherever you post the code) with your framework's normal CSRF / same-origin defences, exactly as you would any endpoint that establishes a session. - PKCE is what proves the exchanger started the flow, not the nonce. This
package generates the verifier, sends only its S256 challenge to
/pair, and keeps the verifier until the exchange. Whoever completes/tokenmust present the matching verifier, so an intercepted code alone is useless. PKCE is mandatory for every client here — there is noplainfallback and never will be. - The issuer must match the token's
issexactly. It is compared, not normalized. Production ishttps://id.zoreal.com, which is the default; overrideissueronly when you have been given a specific non-production provider URL to point at. Your backend must reject any token whoseissis not exactly the issuer it expects.
And the rule the whole design rests on: this runs in a browser the threat model
treats as attacker-controlled, so nothing it resolves is trusted until your
backend has verified the ID token's signature, iss, aud, exp and nonce
against the JWKS. unsafeClaims is named for exactly that reason.
Every version is published from GitHub Actions with npm provenance: the package page on npmjs.com carries a Provenance panel linking the exact commit and workflow run that built the tarball, signed through Sigstore and recorded in its public transparency log. No long-lived npm token stands behind it — the workflow authenticates by OIDC (trusted publishing), so a leaked CI secret cannot cut a release.
Check the signatures on what you actually installed:
npm install @zoreal/oauth2-js
npm audit signatures| Repository | Package | Role |
|---|---|---|
| zoreal-oauth2-react | @zoreal/oauth2-react (npm) | React frontend: the button, the QR, the polling |
| zoreal-oauth2-js | @zoreal/oauth2-js (npm) | Framework-free browser core |
| zoreal-oauth2-react-native | @zoreal/oauth2-react-native (npm) | React Native frontend |
| zoreal-oauth2-node | @zoreal/oauth2-node (npm) | Node.js backend |
| zoreal-oauth2-ruby | zoreal-oauth2 (RubyGems) | Ruby backend |
| zoreal-oauth2-python | zoreal-oauth2 (PyPI) | Python backend |
| zoreal-oauth2-php | zoreal/oauth2 (Packagist) | PHP backend |
| zoreal-oauth2-go | github.com/Bynn-Intelligence/zoreal-oauth2-go | Go backend |
| zoreal-oauth2-java | com.zoreal:oauth2 (Maven Central) | JVM backend |
| zoreal-oauth2-dotnet | Zoreal.OAuth2 (NuGet) | .NET backend |
MIT.