@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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/jwt.ts","../src/wire.ts","../src/pairing.ts","../src/pkce.ts","../src/login.ts"],"sourcesContent":["export { startLogin } from './login';\nexport {\n FlowAbandonedError,\n OAuthFlowError,\n exchangeCode,\n isMobileUserAgent,\n pollUntilApproved,\n startPairing,\n} from './pairing';\nexport type { StartPairingParams } from './pairing';\nexport { challengeS256, generateState, generateVerifier } from './pkce';\nexport { unsafeClaims } from './jwt';\nexport {\n DEFAULT_ISSUER,\n POLL_INTERVAL_ENROLLING_MS,\n POLL_INTERVAL_MS,\n SDK_NAME,\n SDK_VERSION,\n WIRE_VERSION,\n} from './wire';\nexport type {\n PairCreated,\n PairImmediate,\n PairStartResponse,\n PairStatusResponse,\n TokenResponse,\n} from './wire';\nexport type {\n AcrValue,\n AuthCodeLoginOptions,\n BrowserDirectLoginOptions,\n ErrorCode,\n LoginHandle,\n NonOAuthError,\n PairingState,\n SelectBy,\n StartLoginOptions,\n ZorealCodeResponse,\n ZorealCredentialResponse,\n} from './types';\n","/**\n * Reads claims OUT of an ID token without verifying it.\n *\n * That is not a shortcut, it is the design: this code runs in a browser the\n * threat model assumes is attacker-controlled, so a signature check here\n * proves nothing to anyone. The token is verified where verification means\n * something: server-side against the JWKS. What this parser feeds is\n * convenience fields (acr on the response object) that the types document as\n * convenience, with the token staying the authority.\n */\n\nexport function unsafeClaims(idToken: string): Record<string, unknown> {\n try {\n const payload = idToken.split('.')[1] ?? '';\n const b64 = payload.replace(/-/g, '+').replace(/_/g, '/');\n const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);\n return JSON.parse(\n new TextDecoder().decode(Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)))\n );\n } catch {\n return {};\n }\n}\n","/**\n * The wire protocol between this package and the ZOREAL OpenID Provider.\n *\n * VERSIONED: a shipped version keeps working until the provider explicitly\n * refuses it, and when it does, the reason is surfaced verbatim. Both the wire\n * version and the package version travel on every pairing request so a refusal\n * can be precise.\n *\n * Endpoints, all relative to the issuer and all CORS-gated on the client's\n * authorized JavaScript origins (the dashboard):\n *\n * POST /pair start a pairing request. Body carries the\n * authorize parameters plus PKCE challenge.\n * Returns { request_id, pair_url, expires_in }\n * or, for prompt=none with a live consented\n * session, { code } immediately.\n * GET /pair/:id/status poll: pending | claimed |\n * approved (with code) | denied | expired |\n * enrolling. Over-polling cancels the request\n * rather than throttling it, so the cadence\n * below is not a suggestion.\n * GET /pair/:id/qr.svg the QR image for the pairing URL, served by\n * the provider so the pairing surface stays\n * changeable at runtime and\n * this package keeps zero dependencies.\n * POST /token the code exchange. Browser-direct mode uses\n * it directly with PKCE and no client secret;\n * auth-code mode leaves it to the RP backend.\n */\n\nexport const WIRE_VERSION = 1;\nexport const SDK_VERSION = '0.1.1';\nexport const SDK_NAME = '@zoreal/oauth2-js';\nexport const DEFAULT_ISSUER = 'https://id.zoreal.com';\n\n/** Pending TTL is short. Poll gently; over-polling cancels the request. */\nexport const POLL_INTERVAL_MS = 2000;\n/** Enrolling extends the window well beyond a normal login; poll slower. */\nexport const POLL_INTERVAL_ENROLLING_MS = 5000;\n\nexport interface PairCreated {\n request_id: string;\n /** https://zoreal.com/qr/<request_id>. The same URL in QR and app link. */\n pair_url: string;\n expires_in: number;\n}\n\nexport interface PairImmediate {\n /** prompt=none resolved silently: consented sector, live session. */\n code: string;\n}\n\nexport type PairStartResponse = PairCreated | PairImmediate;\n\nexport interface PairStatusResponse {\n status: 'pending' | 'claimed' | 'approved' | 'denied' | 'expired' | 'cancelled' | 'enrolling';\n code?: string;\n expires_in?: number;\n enrolment_deadline?: number;\n /** The provider's reason on denial or refusal. Surfaced verbatim, never rewritten. */\n error?: string;\n error_description?: string;\n}\n\nexport interface TokenResponse {\n id_token: string;\n access_token?: string;\n token_type?: string;\n expires_in?: number;\n scope?: string;\n error?: string;\n error_description?: string;\n}\n","/**\n * The pairing channel, client side. wire.ts pins the endpoints.\n *\n * The browser polls; the phone never talks to the browser. Everything here is\n * therefore plain fetch against the issuer, CORS-gated on the client's\n * authorized origins, with the poll cadence fixed: the provider cancels an\n * over-polling request rather than throttling it, so a \"retry\n * faster on error\" strategy here would kill the login it is trying to save.\n */\n\nimport {\n POLL_INTERVAL_ENROLLING_MS,\n POLL_INTERVAL_MS,\n SDK_NAME,\n SDK_VERSION,\n WIRE_VERSION,\n type PairStartResponse,\n type PairStatusResponse,\n type TokenResponse,\n} from './wire';\nimport type { ErrorCode, NonOAuthError, PairingState } from './types';\n\nexport class OAuthFlowError extends Error {\n constructor(\n public error: ErrorCode,\n public description?: string\n ) {\n super(description ?? error);\n }\n}\n\nexport class FlowAbandonedError extends Error {\n constructor(public reason: NonOAuthError) {\n super(reason.description ?? reason.type);\n }\n}\n\nexport interface StartPairingParams {\n client_id: string;\n scope: string;\n state: string;\n nonce: string;\n code_challenge: string;\n redirect_uri?: string;\n acr_values?: string;\n max_age?: number;\n prompt?: string;\n locale?: string;\n}\n\nasync function parseJson(response: Response): Promise<Record<string, unknown>> {\n try {\n return (await response.json()) as Record<string, unknown>;\n } catch {\n return {};\n }\n}\n\nexport async function startPairing(\n issuer: string,\n params: StartPairingParams\n): Promise<PairStartResponse> {\n const response = await fetch(`${issuer}/pair`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n ...params,\n code_challenge_method: 'S256',\n wire_version: WIRE_VERSION,\n sdk: `${SDK_NAME}/${SDK_VERSION}`,\n }),\n });\n\n const body = await parseJson(response);\n if (!response.ok) {\n // The provider's words, verbatim. A refused package version arrives here,\n // and rewriting its reason would hide the only signal telling an integrator\n // to upgrade.\n throw new OAuthFlowError(\n (body.error as ErrorCode) ?? 'server_error',\n (body.error_description as string) ?? `The provider refused the request (${response.status})`\n );\n }\n return body as unknown as PairStartResponse;\n}\n\nconst sleep = (ms: number, signal?: AbortSignal) =>\n new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(new DOMException('aborted', 'AbortError'));\n return;\n }\n const t = setTimeout(resolve, ms);\n signal?.addEventListener('abort', () => {\n clearTimeout(t);\n reject(new DOMException('aborted', 'AbortError'));\n });\n });\n\n/**\n * Polls until the request resolves. Returns the authorization code.\n * Throws FlowAbandonedError for the human outcomes (denied, expired,\n * enrolment abandoned) and OAuthFlowError for protocol ones.\n */\nexport async function pollUntilApproved(\n issuer: string,\n requestId: string,\n onState?: (state: PairingState) => void,\n signal?: AbortSignal\n): Promise<string> {\n for (;;) {\n const response = await fetch(`${issuer}/pair/${encodeURIComponent(requestId)}/status`, {\n signal,\n });\n const body = (await parseJson(response)) as unknown as PairStatusResponse;\n\n if (!response.ok) {\n throw new OAuthFlowError(\n (body.error as ErrorCode) ?? 'server_error',\n body.error_description ?? `Pairing status failed (${response.status})`\n );\n }\n\n onState?.({\n status: body.status,\n expiresIn: body.expires_in,\n enrolmentDeadline: body.enrolment_deadline,\n });\n\n switch (body.status) {\n case 'approved':\n if (!body.code) {\n throw new OAuthFlowError('server_error', 'approved with no authorization code');\n }\n return body.code;\n case 'denied':\n throw new FlowAbandonedError({ type: 'request_denied', description: body.error_description });\n case 'expired':\n throw new FlowAbandonedError({ type: 'request_expired', description: body.error_description });\n case 'cancelled':\n // The provider cancels an over-polled or abandoned request outright\n // (its pairing rows have a real cancelled state); a poll that treats\n // it as unknown spins on a dead request forever.\n throw new FlowAbandonedError({\n type: 'request_expired',\n description: body.error_description ?? 'the provider cancelled the pairing request',\n });\n case 'enrolling':\n await sleep(POLL_INTERVAL_ENROLLING_MS, signal);\n break;\n default:\n await sleep(POLL_INTERVAL_MS, signal);\n }\n }\n}\n\n/**\n * The code exchange, browser-direct mode only: a public client, PKCE and no\n * secret. What comes back can only ever be the pseudonymous tier, by\n * construction rather than by rule: personal data lives at /userinfo behind an\n * access token this mode is never issued, because personal-data scopes are\n * refused for public clients at the pairing step.\n */\nexport async function exchangeCode(\n issuer: string,\n input: { code: string; code_verifier: string; client_id: string }\n): Promise<TokenResponse> {\n const response = await fetch(`${issuer}/token`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'authorization_code',\n code: input.code,\n code_verifier: input.code_verifier,\n client_id: input.client_id,\n }),\n });\n\n const body = (await parseJson(response)) as unknown as TokenResponse;\n if (!response.ok || body.error) {\n throw new OAuthFlowError(\n (body.error as ErrorCode) ?? 'server_error',\n body.error_description ?? `Token exchange failed (${response.status})`\n );\n }\n return body;\n}\n\n/** A mobile user agent gets the app link, not a QR of its own screen. */\nexport function isMobileUserAgent(): boolean {\n if (typeof navigator === 'undefined') return false;\n return /android|iphone|ipad|ipod/i.test(navigator.userAgent);\n}\n","/**\n * PKCE, S256 only: mandatory for every client, confidential ones included.\n * There is no plain fallback and there must never be one; a provider seeing\n * method=plain is seeing a bug or an attack.\n */\n\nconst VERIFIER_BYTES = 32; // 43 base64url chars, the RFC 7636 minimum length\n\nconst base64url = (bytes: Uint8Array): string =>\n btoa(String.fromCharCode(...bytes))\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '');\n\nexport function generateVerifier(): string {\n const bytes = new Uint8Array(VERIFIER_BYTES);\n crypto.getRandomValues(bytes);\n return base64url(bytes);\n}\n\nexport async function challengeS256(verifier: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));\n return base64url(new Uint8Array(digest));\n}\n\nexport function generateState(): string {\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n return base64url(bytes);\n}\n","/**\n * The one flow, as an imperative handle. This is the same state machine the\n * React SDK's hook runs, without the React: start a pairing, surface it for\n * rendering through onState, poll, and finish per mode. Browser-direct\n * exchanges the code here (public client, PKCE, no secret) and hands over an\n * ID token; auth-code hands the code and the PKCE verifier to the caller,\n * whose backend does the exchange with its client authentication.\n *\n * A framework wrapper owns exactly two things: calling startLogin on the\n * user's gesture, and rendering what onState carries. Everything else -\n * PKCE, state, nonce, cadence, cancellation - lives here.\n */\n\nimport { unsafeClaims } from './jwt';\nimport {\n FlowAbandonedError,\n OAuthFlowError,\n exchangeCode,\n isMobileUserAgent,\n pollUntilApproved,\n startPairing,\n} from './pairing';\nimport { challengeS256, generateState, generateVerifier } from './pkce';\nimport { DEFAULT_ISSUER } from './wire';\nimport type {\n AcrValue,\n AuthCodeLoginOptions,\n BrowserDirectLoginOptions,\n LoginHandle,\n SelectBy,\n ZorealCodeResponse,\n ZorealCredentialResponse,\n} from './types';\n\nexport function startLogin(\n options: BrowserDirectLoginOptions\n): LoginHandle<ZorealCredentialResponse>;\nexport function startLogin(options: AuthCodeLoginOptions): LoginHandle<ZorealCodeResponse>;\nexport function startLogin(\n options: BrowserDirectLoginOptions | AuthCodeLoginOptions\n): LoginHandle<ZorealCredentialResponse> | LoginHandle<ZorealCodeResponse> {\n if ('ux_mode' in options && options.ux_mode === 'redirect') {\n // The popup shape only: the code and PKCE verifier resolve the promise\n // and go from there to your backend over TLS. A redirect would have to\n // carry the verifier in a URL, which is a credential in every access\n // log on the path. Refused loudly rather than implemented badly.\n throw new Error(\n \"@zoreal/oauth2-js: ux_mode 'redirect' is not supported. Use the default \" +\n \"'popup' shape and post the code and code_verifier from the resolved \" +\n 'promise to your backend.'\n );\n }\n\n const flow = options.flow ?? 'browser-direct';\n const issuer = options.issuer ?? DEFAULT_ISSUER;\n const controller = new AbortController();\n\n const surface: {\n requestId?: string;\n pairUrl?: string;\n qrUrl?: string;\n appLink?: boolean;\n } = {};\n\n const cancel = () => controller.abort();\n\n const run = async (): Promise<ZorealCredentialResponse | ZorealCodeResponse> => {\n const verifier = generateVerifier();\n const state = generateState();\n const nonce = generateState();\n\n try {\n const started = await startPairing(issuer, {\n client_id: options.clientId,\n scope: options.scope ?? 'openid',\n state,\n nonce,\n code_challenge: await challengeS256(verifier),\n redirect_uri: flow === 'auth-code' ? (options as AuthCodeLoginOptions).redirect_uri : undefined,\n acr_values: Array.isArray(options.acr_values)\n ? options.acr_values.join(' ')\n : options.acr_values,\n max_age: options.max_age,\n prompt: options.prompt,\n locale: options.locale,\n });\n\n let code: string;\n let selectBy: SelectBy = 'device';\n\n if ('code' in started) {\n // prompt=none resolved silently: consented sector, live session.\n code = started.code;\n selectBy = 'session';\n } else {\n const useAppLink =\n options.display === 'link' || (options.display !== 'qr' && isMobileUserAgent());\n selectBy = useAppLink ? 'app_link' : 'qr';\n\n surface.requestId = started.request_id;\n surface.pairUrl = started.pair_url;\n surface.qrUrl = `${issuer}/pair/${encodeURIComponent(started.request_id)}/qr.svg`;\n surface.appLink = useAppLink;\n\n // Everything a caller-rendered pairing UI needs, on every state it\n // sees: the QR flow cannot complete unless SOMETHING renders pairUrl,\n // and in this package that something is always the caller.\n const stateSurface = {\n pairUrl: surface.pairUrl,\n qrUrl: surface.qrUrl,\n appLink: useAppLink,\n cancel,\n };\n\n // The initial state, immediately: the first poll response is one\n // round-trip away, and a UI that waits for it opens visibly empty.\n options.onState?.({ status: 'pending', expiresIn: started.expires_in, ...stateSurface });\n\n if (useAppLink && typeof window !== 'undefined') {\n // The universal link, in the same tab: the app claims it, and with\n // no app installed the same URL is the real pairing page, which can\n // enrol. A popup here would be blocked more often than it would\n // help.\n window.location.assign(started.pair_url);\n }\n\n code = await pollUntilApproved(\n issuer,\n started.request_id,\n (s) => options.onState?.({ ...s, ...stateSurface }),\n controller.signal\n );\n }\n\n if (flow === 'auth-code') {\n const response: ZorealCodeResponse = {\n code,\n scope: options.scope ?? 'openid',\n app_state: options.app_state,\n code_verifier: verifier,\n nonce,\n };\n return response;\n }\n\n const tokens = await exchangeCode(issuer, {\n code,\n code_verifier: verifier,\n client_id: options.clientId,\n });\n const claims = unsafeClaims(tokens.id_token);\n const response: ZorealCredentialResponse = {\n credential: tokens.id_token,\n clientId: options.clientId,\n select_by: selectBy,\n acr: (claims.acr as AcrValue) ?? 'zoreal.device',\n };\n return response;\n } catch (e) {\n // The taxonomy the promise rejects with, and nothing else:\n // OAuthFlowError the provider refused; reason verbatim\n // FlowAbandonedError a human outcome, or a failure that never\n // reached the provider (network, unknown)\n // AbortError the caller's own cancel()\n if (e instanceof DOMException && e.name === 'AbortError') throw e;\n if (e instanceof OAuthFlowError || e instanceof FlowAbandonedError) throw e;\n throw new FlowAbandonedError({\n type: 'unknown',\n description: e instanceof Error ? e.message : String(e),\n });\n }\n };\n\n const promise = run();\n // A caller driving everything from onState and cancel() may never attach a\n // rejection handler; this no-op one keeps a cancelled login from surfacing\n // as an unhandled rejection. The caller's own catch still sees the error.\n promise.catch(() => {});\n\n return {\n promise: promise as Promise<ZorealCredentialResponse> & Promise<ZorealCodeResponse>,\n cancel,\n get requestId() {\n return surface.requestId;\n },\n get pairUrl() {\n return surface.pairUrl;\n },\n get qrUrl() {\n return surface.qrUrl;\n },\n get appLink() {\n return surface.appLink;\n },\n };\n}\n"],"mappings":"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","names":["response"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public types of @zoreal/oauth2-js.
|
|
3
|
+
*
|
|
4
|
+
* This package is the framework-free browser core of the family: the same
|
|
5
|
+
* wire, states, and error taxonomy as @zoreal/oauth2-react, without the React.
|
|
6
|
+
* A framework wrapper (Vue, Svelte, Angular) builds its UI on `startLogin`
|
|
7
|
+
* and shares these types with the React SDK name for name.
|
|
8
|
+
*/
|
|
9
|
+
type ErrorCode = 'invalid_request' | 'access_denied' | 'unauthorized_client' | 'unsupported_response_type' | 'invalid_scope' | 'server_error' | 'temporarily_unavailable' | 'login_required' | 'consent_required' | 'interaction_required';
|
|
10
|
+
/** Failures that are not OAuth errors, because the flow never reached the provider. */
|
|
11
|
+
type NonOAuthError = {
|
|
12
|
+
type: 'popup_failed_to_open' | 'popup_closed' | 'request_expired' | 'request_denied' | 'enrolment_abandoned' | 'platform_unsupported' | 'unknown';
|
|
13
|
+
/** The provider's own reason string. Render it. Never substitute a friendlier guess. */
|
|
14
|
+
description?: string;
|
|
15
|
+
};
|
|
16
|
+
/** How the holder reached this login. */
|
|
17
|
+
type SelectBy = 'qr' | 'app_link' | 'device' | 'session';
|
|
18
|
+
/** How the login was actually authenticated. Describes what happened, never what was requested. */
|
|
19
|
+
type AcrValue = 'zoreal.live' | 'zoreal.device' | 'zoreal.session';
|
|
20
|
+
interface PairingState {
|
|
21
|
+
status: 'pending' | 'claimed' | 'approved' | 'denied' | 'expired' | 'cancelled' | 'enrolling';
|
|
22
|
+
/** Present while status is 'pending'. Seconds. */
|
|
23
|
+
expiresIn?: number;
|
|
24
|
+
/** Present while status is 'enrolling'. Enrolment extends the window well beyond a normal login. */
|
|
25
|
+
enrolmentDeadline?: number;
|
|
26
|
+
/**
|
|
27
|
+
* The pairing link and its provider-served QR image. Present on every
|
|
28
|
+
* callback of a QR/link flow: the QR flow cannot complete unless SOMETHING
|
|
29
|
+
* renders pairUrl, and in this package that something is always the caller.
|
|
30
|
+
*/
|
|
31
|
+
pairUrl?: string;
|
|
32
|
+
/** The provider-served SVG of pairUrl. Put it in an <img>; do not draw your own. */
|
|
33
|
+
qrUrl?: string;
|
|
34
|
+
/** True when the flow resolved to the app link (mobile) rather than a QR. */
|
|
35
|
+
appLink?: boolean;
|
|
36
|
+
/** Abandons this pairing: stops the poll. Wire it to your UI's cancel control. */
|
|
37
|
+
cancel?: () => void;
|
|
38
|
+
}
|
|
39
|
+
interface ZorealCredentialResponse {
|
|
40
|
+
/** The ID token. Verify it server-side against the JWKS before trusting it. */
|
|
41
|
+
credential: string;
|
|
42
|
+
clientId: string;
|
|
43
|
+
select_by: SelectBy;
|
|
44
|
+
/** Convenience, parsed from the token. The token stays the authority. */
|
|
45
|
+
acr: AcrValue;
|
|
46
|
+
}
|
|
47
|
+
interface ZorealCodeResponse {
|
|
48
|
+
code: string;
|
|
49
|
+
scope: string;
|
|
50
|
+
app_state?: string;
|
|
51
|
+
/**
|
|
52
|
+
* The PKCE verifier for this code. Post it to your backend with the code;
|
|
53
|
+
* the backend sends both to /token along with its client authentication.
|
|
54
|
+
* PKCE is mandatory for every client, and the verifier is generated here, so
|
|
55
|
+
* your server can only complete the exchange if this hands it over. It travels
|
|
56
|
+
* to YOUR backend over TLS and nowhere else.
|
|
57
|
+
*/
|
|
58
|
+
code_verifier: string;
|
|
59
|
+
/**
|
|
60
|
+
* The nonce this package generated for this flow. The ID token carries it,
|
|
61
|
+
* and without handing it over the backend doing the exchange has no way to
|
|
62
|
+
* check the token it receives was minted for this login rather than
|
|
63
|
+
* substituted. Verify it against the ID token's nonce claim, alongside iss,
|
|
64
|
+
* aud and exp. Same travel rule as code_verifier.
|
|
65
|
+
*/
|
|
66
|
+
nonce: string;
|
|
67
|
+
}
|
|
68
|
+
interface StartLoginOptions {
|
|
69
|
+
/** The asset token from the ZOREAL dashboard, ast_... */
|
|
70
|
+
clientId: string;
|
|
71
|
+
/** Defaults to https://id.zoreal.com. Sandbox and self-hosted providers override it. */
|
|
72
|
+
issuer?: string;
|
|
73
|
+
/** Defaults to 'openid'. Scopes that return personal data require flow: 'auth-code'. */
|
|
74
|
+
scope?: string;
|
|
75
|
+
/** Ask for a specific assurance. Omit to accept the default, zoreal.device. */
|
|
76
|
+
acr_values?: AcrValue | AcrValue[];
|
|
77
|
+
/** Seconds. Forces re-authentication when auth_time is older. */
|
|
78
|
+
max_age?: number;
|
|
79
|
+
prompt?: 'none' | 'login' | 'consent';
|
|
80
|
+
/** Echoed back. Not a CSRF token: the package generates its own state and PKCE verifier. */
|
|
81
|
+
app_state?: string;
|
|
82
|
+
/** 'auto' renders a QR on desktop and an app link on mobile, which is what you want. */
|
|
83
|
+
display?: 'auto' | 'qr' | 'link';
|
|
84
|
+
/** Sent to the provider so the pairing surface speaks the visitor's language. */
|
|
85
|
+
locale?: string;
|
|
86
|
+
/** Called on each pairing state change. Drive your UI from this. */
|
|
87
|
+
onState?: (state: PairingState) => void;
|
|
88
|
+
}
|
|
89
|
+
interface BrowserDirectLoginOptions extends StartLoginOptions {
|
|
90
|
+
flow?: 'browser-direct';
|
|
91
|
+
}
|
|
92
|
+
interface AuthCodeLoginOptions extends StartLoginOptions {
|
|
93
|
+
flow: 'auth-code';
|
|
94
|
+
/** Must be registered for this client in the ZOREAL dashboard. */
|
|
95
|
+
redirect_uri?: string;
|
|
96
|
+
ux_mode?: 'popup' | 'redirect';
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* What startLogin returns, synchronously. The promise settles when the flow
|
|
100
|
+
* does; requestId, pairUrl, qrUrl and appLink fill in once the provider has
|
|
101
|
+
* created the pairing request (they also arrive on every onState callback,
|
|
102
|
+
* which is the reliable place to render from).
|
|
103
|
+
*/
|
|
104
|
+
interface LoginHandle<T> {
|
|
105
|
+
/**
|
|
106
|
+
* Resolves with the mode's result: a ZorealCredentialResponse in
|
|
107
|
+
* browser-direct mode, a ZorealCodeResponse in auth-code mode. Rejects with
|
|
108
|
+
* OAuthFlowError (the provider refused, reason verbatim),
|
|
109
|
+
* FlowAbandonedError (a human outcome: denied, expired, abandoned), or a
|
|
110
|
+
* DOMException named AbortError after cancel().
|
|
111
|
+
*/
|
|
112
|
+
promise: Promise<T>;
|
|
113
|
+
/** Abandons the flow: stops the poll and rejects the promise with AbortError. */
|
|
114
|
+
cancel: () => void;
|
|
115
|
+
/** The pairing request id, once created. Undefined before, and for prompt=none immediate codes. */
|
|
116
|
+
readonly requestId: string | undefined;
|
|
117
|
+
/** The pairing URL, once created. The same URL in QR and app link. */
|
|
118
|
+
readonly pairUrl: string | undefined;
|
|
119
|
+
/** The provider-served QR image of pairUrl. */
|
|
120
|
+
readonly qrUrl: string | undefined;
|
|
121
|
+
/** True when display resolved to the app link rather than the QR. */
|
|
122
|
+
readonly appLink: boolean | undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The one flow, as an imperative handle. This is the same state machine the
|
|
127
|
+
* React SDK's hook runs, without the React: start a pairing, surface it for
|
|
128
|
+
* rendering through onState, poll, and finish per mode. Browser-direct
|
|
129
|
+
* exchanges the code here (public client, PKCE, no secret) and hands over an
|
|
130
|
+
* ID token; auth-code hands the code and the PKCE verifier to the caller,
|
|
131
|
+
* whose backend does the exchange with its client authentication.
|
|
132
|
+
*
|
|
133
|
+
* A framework wrapper owns exactly two things: calling startLogin on the
|
|
134
|
+
* user's gesture, and rendering what onState carries. Everything else -
|
|
135
|
+
* PKCE, state, nonce, cadence, cancellation - lives here.
|
|
136
|
+
*/
|
|
137
|
+
|
|
138
|
+
declare function startLogin(options: BrowserDirectLoginOptions): LoginHandle<ZorealCredentialResponse>;
|
|
139
|
+
declare function startLogin(options: AuthCodeLoginOptions): LoginHandle<ZorealCodeResponse>;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The wire protocol between this package and the ZOREAL OpenID Provider.
|
|
143
|
+
*
|
|
144
|
+
* VERSIONED: a shipped version keeps working until the provider explicitly
|
|
145
|
+
* refuses it, and when it does, the reason is surfaced verbatim. Both the wire
|
|
146
|
+
* version and the package version travel on every pairing request so a refusal
|
|
147
|
+
* can be precise.
|
|
148
|
+
*
|
|
149
|
+
* Endpoints, all relative to the issuer and all CORS-gated on the client's
|
|
150
|
+
* authorized JavaScript origins (the dashboard):
|
|
151
|
+
*
|
|
152
|
+
* POST /pair start a pairing request. Body carries the
|
|
153
|
+
* authorize parameters plus PKCE challenge.
|
|
154
|
+
* Returns { request_id, pair_url, expires_in }
|
|
155
|
+
* or, for prompt=none with a live consented
|
|
156
|
+
* session, { code } immediately.
|
|
157
|
+
* GET /pair/:id/status poll: pending | claimed |
|
|
158
|
+
* approved (with code) | denied | expired |
|
|
159
|
+
* enrolling. Over-polling cancels the request
|
|
160
|
+
* rather than throttling it, so the cadence
|
|
161
|
+
* below is not a suggestion.
|
|
162
|
+
* GET /pair/:id/qr.svg the QR image for the pairing URL, served by
|
|
163
|
+
* the provider so the pairing surface stays
|
|
164
|
+
* changeable at runtime and
|
|
165
|
+
* this package keeps zero dependencies.
|
|
166
|
+
* POST /token the code exchange. Browser-direct mode uses
|
|
167
|
+
* it directly with PKCE and no client secret;
|
|
168
|
+
* auth-code mode leaves it to the RP backend.
|
|
169
|
+
*/
|
|
170
|
+
declare const WIRE_VERSION = 1;
|
|
171
|
+
declare const SDK_VERSION = "0.1.1";
|
|
172
|
+
declare const SDK_NAME = "@zoreal/oauth2-js";
|
|
173
|
+
declare const DEFAULT_ISSUER = "https://id.zoreal.com";
|
|
174
|
+
/** Pending TTL is short. Poll gently; over-polling cancels the request. */
|
|
175
|
+
declare const POLL_INTERVAL_MS = 2000;
|
|
176
|
+
/** Enrolling extends the window well beyond a normal login; poll slower. */
|
|
177
|
+
declare const POLL_INTERVAL_ENROLLING_MS = 5000;
|
|
178
|
+
interface PairCreated {
|
|
179
|
+
request_id: string;
|
|
180
|
+
/** https://zoreal.com/qr/<request_id>. The same URL in QR and app link. */
|
|
181
|
+
pair_url: string;
|
|
182
|
+
expires_in: number;
|
|
183
|
+
}
|
|
184
|
+
interface PairImmediate {
|
|
185
|
+
/** prompt=none resolved silently: consented sector, live session. */
|
|
186
|
+
code: string;
|
|
187
|
+
}
|
|
188
|
+
type PairStartResponse = PairCreated | PairImmediate;
|
|
189
|
+
interface PairStatusResponse {
|
|
190
|
+
status: 'pending' | 'claimed' | 'approved' | 'denied' | 'expired' | 'cancelled' | 'enrolling';
|
|
191
|
+
code?: string;
|
|
192
|
+
expires_in?: number;
|
|
193
|
+
enrolment_deadline?: number;
|
|
194
|
+
/** The provider's reason on denial or refusal. Surfaced verbatim, never rewritten. */
|
|
195
|
+
error?: string;
|
|
196
|
+
error_description?: string;
|
|
197
|
+
}
|
|
198
|
+
interface TokenResponse {
|
|
199
|
+
id_token: string;
|
|
200
|
+
access_token?: string;
|
|
201
|
+
token_type?: string;
|
|
202
|
+
expires_in?: number;
|
|
203
|
+
scope?: string;
|
|
204
|
+
error?: string;
|
|
205
|
+
error_description?: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The pairing channel, client side. wire.ts pins the endpoints.
|
|
210
|
+
*
|
|
211
|
+
* The browser polls; the phone never talks to the browser. Everything here is
|
|
212
|
+
* therefore plain fetch against the issuer, CORS-gated on the client's
|
|
213
|
+
* authorized origins, with the poll cadence fixed: the provider cancels an
|
|
214
|
+
* over-polling request rather than throttling it, so a "retry
|
|
215
|
+
* faster on error" strategy here would kill the login it is trying to save.
|
|
216
|
+
*/
|
|
217
|
+
|
|
218
|
+
declare class OAuthFlowError extends Error {
|
|
219
|
+
error: ErrorCode;
|
|
220
|
+
description?: string | undefined;
|
|
221
|
+
constructor(error: ErrorCode, description?: string | undefined);
|
|
222
|
+
}
|
|
223
|
+
declare class FlowAbandonedError extends Error {
|
|
224
|
+
reason: NonOAuthError;
|
|
225
|
+
constructor(reason: NonOAuthError);
|
|
226
|
+
}
|
|
227
|
+
interface StartPairingParams {
|
|
228
|
+
client_id: string;
|
|
229
|
+
scope: string;
|
|
230
|
+
state: string;
|
|
231
|
+
nonce: string;
|
|
232
|
+
code_challenge: string;
|
|
233
|
+
redirect_uri?: string;
|
|
234
|
+
acr_values?: string;
|
|
235
|
+
max_age?: number;
|
|
236
|
+
prompt?: string;
|
|
237
|
+
locale?: string;
|
|
238
|
+
}
|
|
239
|
+
declare function startPairing(issuer: string, params: StartPairingParams): Promise<PairStartResponse>;
|
|
240
|
+
/**
|
|
241
|
+
* Polls until the request resolves. Returns the authorization code.
|
|
242
|
+
* Throws FlowAbandonedError for the human outcomes (denied, expired,
|
|
243
|
+
* enrolment abandoned) and OAuthFlowError for protocol ones.
|
|
244
|
+
*/
|
|
245
|
+
declare function pollUntilApproved(issuer: string, requestId: string, onState?: (state: PairingState) => void, signal?: AbortSignal): Promise<string>;
|
|
246
|
+
/**
|
|
247
|
+
* The code exchange, browser-direct mode only: a public client, PKCE and no
|
|
248
|
+
* secret. What comes back can only ever be the pseudonymous tier, by
|
|
249
|
+
* construction rather than by rule: personal data lives at /userinfo behind an
|
|
250
|
+
* access token this mode is never issued, because personal-data scopes are
|
|
251
|
+
* refused for public clients at the pairing step.
|
|
252
|
+
*/
|
|
253
|
+
declare function exchangeCode(issuer: string, input: {
|
|
254
|
+
code: string;
|
|
255
|
+
code_verifier: string;
|
|
256
|
+
client_id: string;
|
|
257
|
+
}): Promise<TokenResponse>;
|
|
258
|
+
/** A mobile user agent gets the app link, not a QR of its own screen. */
|
|
259
|
+
declare function isMobileUserAgent(): boolean;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* PKCE, S256 only: mandatory for every client, confidential ones included.
|
|
263
|
+
* There is no plain fallback and there must never be one; a provider seeing
|
|
264
|
+
* method=plain is seeing a bug or an attack.
|
|
265
|
+
*/
|
|
266
|
+
declare function generateVerifier(): string;
|
|
267
|
+
declare function challengeS256(verifier: string): Promise<string>;
|
|
268
|
+
declare function generateState(): string;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Reads claims OUT of an ID token without verifying it.
|
|
272
|
+
*
|
|
273
|
+
* That is not a shortcut, it is the design: this code runs in a browser the
|
|
274
|
+
* threat model assumes is attacker-controlled, so a signature check here
|
|
275
|
+
* proves nothing to anyone. The token is verified where verification means
|
|
276
|
+
* something: server-side against the JWKS. What this parser feeds is
|
|
277
|
+
* convenience fields (acr on the response object) that the types document as
|
|
278
|
+
* convenience, with the token staying the authority.
|
|
279
|
+
*/
|
|
280
|
+
declare function unsafeClaims(idToken: string): Record<string, unknown>;
|
|
281
|
+
|
|
282
|
+
export { type AcrValue, type AuthCodeLoginOptions, type BrowserDirectLoginOptions, DEFAULT_ISSUER, type ErrorCode, FlowAbandonedError, type LoginHandle, type NonOAuthError, OAuthFlowError, POLL_INTERVAL_ENROLLING_MS, POLL_INTERVAL_MS, type PairCreated, type PairImmediate, type PairStartResponse, type PairStatusResponse, type PairingState, SDK_NAME, SDK_VERSION, type SelectBy, type StartLoginOptions, type StartPairingParams, type TokenResponse, WIRE_VERSION, type ZorealCodeResponse, type ZorealCredentialResponse, challengeS256, exchangeCode, generateState, generateVerifier, isMobileUserAgent, pollUntilApproved, startLogin, startPairing, unsafeClaims };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public types of @zoreal/oauth2-js.
|
|
3
|
+
*
|
|
4
|
+
* This package is the framework-free browser core of the family: the same
|
|
5
|
+
* wire, states, and error taxonomy as @zoreal/oauth2-react, without the React.
|
|
6
|
+
* A framework wrapper (Vue, Svelte, Angular) builds its UI on `startLogin`
|
|
7
|
+
* and shares these types with the React SDK name for name.
|
|
8
|
+
*/
|
|
9
|
+
type ErrorCode = 'invalid_request' | 'access_denied' | 'unauthorized_client' | 'unsupported_response_type' | 'invalid_scope' | 'server_error' | 'temporarily_unavailable' | 'login_required' | 'consent_required' | 'interaction_required';
|
|
10
|
+
/** Failures that are not OAuth errors, because the flow never reached the provider. */
|
|
11
|
+
type NonOAuthError = {
|
|
12
|
+
type: 'popup_failed_to_open' | 'popup_closed' | 'request_expired' | 'request_denied' | 'enrolment_abandoned' | 'platform_unsupported' | 'unknown';
|
|
13
|
+
/** The provider's own reason string. Render it. Never substitute a friendlier guess. */
|
|
14
|
+
description?: string;
|
|
15
|
+
};
|
|
16
|
+
/** How the holder reached this login. */
|
|
17
|
+
type SelectBy = 'qr' | 'app_link' | 'device' | 'session';
|
|
18
|
+
/** How the login was actually authenticated. Describes what happened, never what was requested. */
|
|
19
|
+
type AcrValue = 'zoreal.live' | 'zoreal.device' | 'zoreal.session';
|
|
20
|
+
interface PairingState {
|
|
21
|
+
status: 'pending' | 'claimed' | 'approved' | 'denied' | 'expired' | 'cancelled' | 'enrolling';
|
|
22
|
+
/** Present while status is 'pending'. Seconds. */
|
|
23
|
+
expiresIn?: number;
|
|
24
|
+
/** Present while status is 'enrolling'. Enrolment extends the window well beyond a normal login. */
|
|
25
|
+
enrolmentDeadline?: number;
|
|
26
|
+
/**
|
|
27
|
+
* The pairing link and its provider-served QR image. Present on every
|
|
28
|
+
* callback of a QR/link flow: the QR flow cannot complete unless SOMETHING
|
|
29
|
+
* renders pairUrl, and in this package that something is always the caller.
|
|
30
|
+
*/
|
|
31
|
+
pairUrl?: string;
|
|
32
|
+
/** The provider-served SVG of pairUrl. Put it in an <img>; do not draw your own. */
|
|
33
|
+
qrUrl?: string;
|
|
34
|
+
/** True when the flow resolved to the app link (mobile) rather than a QR. */
|
|
35
|
+
appLink?: boolean;
|
|
36
|
+
/** Abandons this pairing: stops the poll. Wire it to your UI's cancel control. */
|
|
37
|
+
cancel?: () => void;
|
|
38
|
+
}
|
|
39
|
+
interface ZorealCredentialResponse {
|
|
40
|
+
/** The ID token. Verify it server-side against the JWKS before trusting it. */
|
|
41
|
+
credential: string;
|
|
42
|
+
clientId: string;
|
|
43
|
+
select_by: SelectBy;
|
|
44
|
+
/** Convenience, parsed from the token. The token stays the authority. */
|
|
45
|
+
acr: AcrValue;
|
|
46
|
+
}
|
|
47
|
+
interface ZorealCodeResponse {
|
|
48
|
+
code: string;
|
|
49
|
+
scope: string;
|
|
50
|
+
app_state?: string;
|
|
51
|
+
/**
|
|
52
|
+
* The PKCE verifier for this code. Post it to your backend with the code;
|
|
53
|
+
* the backend sends both to /token along with its client authentication.
|
|
54
|
+
* PKCE is mandatory for every client, and the verifier is generated here, so
|
|
55
|
+
* your server can only complete the exchange if this hands it over. It travels
|
|
56
|
+
* to YOUR backend over TLS and nowhere else.
|
|
57
|
+
*/
|
|
58
|
+
code_verifier: string;
|
|
59
|
+
/**
|
|
60
|
+
* The nonce this package generated for this flow. The ID token carries it,
|
|
61
|
+
* and without handing it over the backend doing the exchange has no way to
|
|
62
|
+
* check the token it receives was minted for this login rather than
|
|
63
|
+
* substituted. Verify it against the ID token's nonce claim, alongside iss,
|
|
64
|
+
* aud and exp. Same travel rule as code_verifier.
|
|
65
|
+
*/
|
|
66
|
+
nonce: string;
|
|
67
|
+
}
|
|
68
|
+
interface StartLoginOptions {
|
|
69
|
+
/** The asset token from the ZOREAL dashboard, ast_... */
|
|
70
|
+
clientId: string;
|
|
71
|
+
/** Defaults to https://id.zoreal.com. Sandbox and self-hosted providers override it. */
|
|
72
|
+
issuer?: string;
|
|
73
|
+
/** Defaults to 'openid'. Scopes that return personal data require flow: 'auth-code'. */
|
|
74
|
+
scope?: string;
|
|
75
|
+
/** Ask for a specific assurance. Omit to accept the default, zoreal.device. */
|
|
76
|
+
acr_values?: AcrValue | AcrValue[];
|
|
77
|
+
/** Seconds. Forces re-authentication when auth_time is older. */
|
|
78
|
+
max_age?: number;
|
|
79
|
+
prompt?: 'none' | 'login' | 'consent';
|
|
80
|
+
/** Echoed back. Not a CSRF token: the package generates its own state and PKCE verifier. */
|
|
81
|
+
app_state?: string;
|
|
82
|
+
/** 'auto' renders a QR on desktop and an app link on mobile, which is what you want. */
|
|
83
|
+
display?: 'auto' | 'qr' | 'link';
|
|
84
|
+
/** Sent to the provider so the pairing surface speaks the visitor's language. */
|
|
85
|
+
locale?: string;
|
|
86
|
+
/** Called on each pairing state change. Drive your UI from this. */
|
|
87
|
+
onState?: (state: PairingState) => void;
|
|
88
|
+
}
|
|
89
|
+
interface BrowserDirectLoginOptions extends StartLoginOptions {
|
|
90
|
+
flow?: 'browser-direct';
|
|
91
|
+
}
|
|
92
|
+
interface AuthCodeLoginOptions extends StartLoginOptions {
|
|
93
|
+
flow: 'auth-code';
|
|
94
|
+
/** Must be registered for this client in the ZOREAL dashboard. */
|
|
95
|
+
redirect_uri?: string;
|
|
96
|
+
ux_mode?: 'popup' | 'redirect';
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* What startLogin returns, synchronously. The promise settles when the flow
|
|
100
|
+
* does; requestId, pairUrl, qrUrl and appLink fill in once the provider has
|
|
101
|
+
* created the pairing request (they also arrive on every onState callback,
|
|
102
|
+
* which is the reliable place to render from).
|
|
103
|
+
*/
|
|
104
|
+
interface LoginHandle<T> {
|
|
105
|
+
/**
|
|
106
|
+
* Resolves with the mode's result: a ZorealCredentialResponse in
|
|
107
|
+
* browser-direct mode, a ZorealCodeResponse in auth-code mode. Rejects with
|
|
108
|
+
* OAuthFlowError (the provider refused, reason verbatim),
|
|
109
|
+
* FlowAbandonedError (a human outcome: denied, expired, abandoned), or a
|
|
110
|
+
* DOMException named AbortError after cancel().
|
|
111
|
+
*/
|
|
112
|
+
promise: Promise<T>;
|
|
113
|
+
/** Abandons the flow: stops the poll and rejects the promise with AbortError. */
|
|
114
|
+
cancel: () => void;
|
|
115
|
+
/** The pairing request id, once created. Undefined before, and for prompt=none immediate codes. */
|
|
116
|
+
readonly requestId: string | undefined;
|
|
117
|
+
/** The pairing URL, once created. The same URL in QR and app link. */
|
|
118
|
+
readonly pairUrl: string | undefined;
|
|
119
|
+
/** The provider-served QR image of pairUrl. */
|
|
120
|
+
readonly qrUrl: string | undefined;
|
|
121
|
+
/** True when display resolved to the app link rather than the QR. */
|
|
122
|
+
readonly appLink: boolean | undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The one flow, as an imperative handle. This is the same state machine the
|
|
127
|
+
* React SDK's hook runs, without the React: start a pairing, surface it for
|
|
128
|
+
* rendering through onState, poll, and finish per mode. Browser-direct
|
|
129
|
+
* exchanges the code here (public client, PKCE, no secret) and hands over an
|
|
130
|
+
* ID token; auth-code hands the code and the PKCE verifier to the caller,
|
|
131
|
+
* whose backend does the exchange with its client authentication.
|
|
132
|
+
*
|
|
133
|
+
* A framework wrapper owns exactly two things: calling startLogin on the
|
|
134
|
+
* user's gesture, and rendering what onState carries. Everything else -
|
|
135
|
+
* PKCE, state, nonce, cadence, cancellation - lives here.
|
|
136
|
+
*/
|
|
137
|
+
|
|
138
|
+
declare function startLogin(options: BrowserDirectLoginOptions): LoginHandle<ZorealCredentialResponse>;
|
|
139
|
+
declare function startLogin(options: AuthCodeLoginOptions): LoginHandle<ZorealCodeResponse>;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The wire protocol between this package and the ZOREAL OpenID Provider.
|
|
143
|
+
*
|
|
144
|
+
* VERSIONED: a shipped version keeps working until the provider explicitly
|
|
145
|
+
* refuses it, and when it does, the reason is surfaced verbatim. Both the wire
|
|
146
|
+
* version and the package version travel on every pairing request so a refusal
|
|
147
|
+
* can be precise.
|
|
148
|
+
*
|
|
149
|
+
* Endpoints, all relative to the issuer and all CORS-gated on the client's
|
|
150
|
+
* authorized JavaScript origins (the dashboard):
|
|
151
|
+
*
|
|
152
|
+
* POST /pair start a pairing request. Body carries the
|
|
153
|
+
* authorize parameters plus PKCE challenge.
|
|
154
|
+
* Returns { request_id, pair_url, expires_in }
|
|
155
|
+
* or, for prompt=none with a live consented
|
|
156
|
+
* session, { code } immediately.
|
|
157
|
+
* GET /pair/:id/status poll: pending | claimed |
|
|
158
|
+
* approved (with code) | denied | expired |
|
|
159
|
+
* enrolling. Over-polling cancels the request
|
|
160
|
+
* rather than throttling it, so the cadence
|
|
161
|
+
* below is not a suggestion.
|
|
162
|
+
* GET /pair/:id/qr.svg the QR image for the pairing URL, served by
|
|
163
|
+
* the provider so the pairing surface stays
|
|
164
|
+
* changeable at runtime and
|
|
165
|
+
* this package keeps zero dependencies.
|
|
166
|
+
* POST /token the code exchange. Browser-direct mode uses
|
|
167
|
+
* it directly with PKCE and no client secret;
|
|
168
|
+
* auth-code mode leaves it to the RP backend.
|
|
169
|
+
*/
|
|
170
|
+
declare const WIRE_VERSION = 1;
|
|
171
|
+
declare const SDK_VERSION = "0.1.1";
|
|
172
|
+
declare const SDK_NAME = "@zoreal/oauth2-js";
|
|
173
|
+
declare const DEFAULT_ISSUER = "https://id.zoreal.com";
|
|
174
|
+
/** Pending TTL is short. Poll gently; over-polling cancels the request. */
|
|
175
|
+
declare const POLL_INTERVAL_MS = 2000;
|
|
176
|
+
/** Enrolling extends the window well beyond a normal login; poll slower. */
|
|
177
|
+
declare const POLL_INTERVAL_ENROLLING_MS = 5000;
|
|
178
|
+
interface PairCreated {
|
|
179
|
+
request_id: string;
|
|
180
|
+
/** https://zoreal.com/qr/<request_id>. The same URL in QR and app link. */
|
|
181
|
+
pair_url: string;
|
|
182
|
+
expires_in: number;
|
|
183
|
+
}
|
|
184
|
+
interface PairImmediate {
|
|
185
|
+
/** prompt=none resolved silently: consented sector, live session. */
|
|
186
|
+
code: string;
|
|
187
|
+
}
|
|
188
|
+
type PairStartResponse = PairCreated | PairImmediate;
|
|
189
|
+
interface PairStatusResponse {
|
|
190
|
+
status: 'pending' | 'claimed' | 'approved' | 'denied' | 'expired' | 'cancelled' | 'enrolling';
|
|
191
|
+
code?: string;
|
|
192
|
+
expires_in?: number;
|
|
193
|
+
enrolment_deadline?: number;
|
|
194
|
+
/** The provider's reason on denial or refusal. Surfaced verbatim, never rewritten. */
|
|
195
|
+
error?: string;
|
|
196
|
+
error_description?: string;
|
|
197
|
+
}
|
|
198
|
+
interface TokenResponse {
|
|
199
|
+
id_token: string;
|
|
200
|
+
access_token?: string;
|
|
201
|
+
token_type?: string;
|
|
202
|
+
expires_in?: number;
|
|
203
|
+
scope?: string;
|
|
204
|
+
error?: string;
|
|
205
|
+
error_description?: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The pairing channel, client side. wire.ts pins the endpoints.
|
|
210
|
+
*
|
|
211
|
+
* The browser polls; the phone never talks to the browser. Everything here is
|
|
212
|
+
* therefore plain fetch against the issuer, CORS-gated on the client's
|
|
213
|
+
* authorized origins, with the poll cadence fixed: the provider cancels an
|
|
214
|
+
* over-polling request rather than throttling it, so a "retry
|
|
215
|
+
* faster on error" strategy here would kill the login it is trying to save.
|
|
216
|
+
*/
|
|
217
|
+
|
|
218
|
+
declare class OAuthFlowError extends Error {
|
|
219
|
+
error: ErrorCode;
|
|
220
|
+
description?: string | undefined;
|
|
221
|
+
constructor(error: ErrorCode, description?: string | undefined);
|
|
222
|
+
}
|
|
223
|
+
declare class FlowAbandonedError extends Error {
|
|
224
|
+
reason: NonOAuthError;
|
|
225
|
+
constructor(reason: NonOAuthError);
|
|
226
|
+
}
|
|
227
|
+
interface StartPairingParams {
|
|
228
|
+
client_id: string;
|
|
229
|
+
scope: string;
|
|
230
|
+
state: string;
|
|
231
|
+
nonce: string;
|
|
232
|
+
code_challenge: string;
|
|
233
|
+
redirect_uri?: string;
|
|
234
|
+
acr_values?: string;
|
|
235
|
+
max_age?: number;
|
|
236
|
+
prompt?: string;
|
|
237
|
+
locale?: string;
|
|
238
|
+
}
|
|
239
|
+
declare function startPairing(issuer: string, params: StartPairingParams): Promise<PairStartResponse>;
|
|
240
|
+
/**
|
|
241
|
+
* Polls until the request resolves. Returns the authorization code.
|
|
242
|
+
* Throws FlowAbandonedError for the human outcomes (denied, expired,
|
|
243
|
+
* enrolment abandoned) and OAuthFlowError for protocol ones.
|
|
244
|
+
*/
|
|
245
|
+
declare function pollUntilApproved(issuer: string, requestId: string, onState?: (state: PairingState) => void, signal?: AbortSignal): Promise<string>;
|
|
246
|
+
/**
|
|
247
|
+
* The code exchange, browser-direct mode only: a public client, PKCE and no
|
|
248
|
+
* secret. What comes back can only ever be the pseudonymous tier, by
|
|
249
|
+
* construction rather than by rule: personal data lives at /userinfo behind an
|
|
250
|
+
* access token this mode is never issued, because personal-data scopes are
|
|
251
|
+
* refused for public clients at the pairing step.
|
|
252
|
+
*/
|
|
253
|
+
declare function exchangeCode(issuer: string, input: {
|
|
254
|
+
code: string;
|
|
255
|
+
code_verifier: string;
|
|
256
|
+
client_id: string;
|
|
257
|
+
}): Promise<TokenResponse>;
|
|
258
|
+
/** A mobile user agent gets the app link, not a QR of its own screen. */
|
|
259
|
+
declare function isMobileUserAgent(): boolean;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* PKCE, S256 only: mandatory for every client, confidential ones included.
|
|
263
|
+
* There is no plain fallback and there must never be one; a provider seeing
|
|
264
|
+
* method=plain is seeing a bug or an attack.
|
|
265
|
+
*/
|
|
266
|
+
declare function generateVerifier(): string;
|
|
267
|
+
declare function challengeS256(verifier: string): Promise<string>;
|
|
268
|
+
declare function generateState(): string;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Reads claims OUT of an ID token without verifying it.
|
|
272
|
+
*
|
|
273
|
+
* That is not a shortcut, it is the design: this code runs in a browser the
|
|
274
|
+
* threat model assumes is attacker-controlled, so a signature check here
|
|
275
|
+
* proves nothing to anyone. The token is verified where verification means
|
|
276
|
+
* something: server-side against the JWKS. What this parser feeds is
|
|
277
|
+
* convenience fields (acr on the response object) that the types document as
|
|
278
|
+
* convenience, with the token staying the authority.
|
|
279
|
+
*/
|
|
280
|
+
declare function unsafeClaims(idToken: string): Record<string, unknown>;
|
|
281
|
+
|
|
282
|
+
export { type AcrValue, type AuthCodeLoginOptions, type BrowserDirectLoginOptions, DEFAULT_ISSUER, type ErrorCode, FlowAbandonedError, type LoginHandle, type NonOAuthError, OAuthFlowError, POLL_INTERVAL_ENROLLING_MS, POLL_INTERVAL_MS, type PairCreated, type PairImmediate, type PairStartResponse, type PairStatusResponse, type PairingState, SDK_NAME, SDK_VERSION, type SelectBy, type StartLoginOptions, type StartPairingParams, type TokenResponse, WIRE_VERSION, type ZorealCodeResponse, type ZorealCredentialResponse, challengeS256, exchangeCode, generateState, generateVerifier, isMobileUserAgent, pollUntilApproved, startLogin, startPairing, unsafeClaims };
|