OIDC Client for JavaScript
Library to provide OpenID Connect (OIDC) and OAuth2 protocol support for client-side, browser-based JavaScript client applications. Also included is support for user session and access token management.
| ID | oidc-client-ts |
| Version | 2.5.0 |
| Source | modules/oidc-client-ts |
Dependencies
Documentation
oidc-client-ts — client-side OIDC (PKCE public)
Browser-side OpenID Connect / OAuth2 login for xOpat, built on the vendored
oidc-client-ts library. It runs the
whole flow in the browser as a PKCE public client (no client_secret),
obtains a token, and hands it to XOpatUser so HttpClient "just works". It is
the default OIDC provider.
For IdPs that require a confidential client (a
client_secret), use the server-sideoidc-server-tsmodule instead — a secret shipped to the browser is insecure (this module warns and still proceeds PKCE-style). The canonical auth model is documented insrc/AUTH.md.
Runnable example. test/fixtures/keycloak/
brings up a Keycloak with a public PKCE client already registered, and
test/env/oidc.json is a complete deployment against
it — contexts, the oidc RPC verifier, and the role rules its groups claim
drives. npm test -- --project=oidc drives the login for real. The same fixture
backs the SAML deployment next to it, which is how the "a feature names a
context, never a mechanism" claim is checked rather than asserted.
Purpose
- Perform automated login + silent token refresh against an OIDC IdP.
- Register an
"oidc"broker into the core auth registry (APPLICATION_CONTEXT.auth,XOpatAuth), so features can require login for a named context without touching OIDC code. - Attach the obtained JWT to upstream requests (via
XOpatUser+HttpClient).
Behavior
- Auto-declared contexts (
auth-broker.js): at boot the broker reads this module's public static config and declares each context intoAPPLICATION_CONTEXT.auth— so the main viewer login needs no plugin or feature code (this replaced the removedoidc-authplugin). Convention: the default / main context = the main viewer identity (updates the appbar user + the defaultXOpatUser/HttpClientcontext); any other id is a sub-identity. Key the default context as""/null/"core"(all equivalent — they canonicalize to"core"and fire the barelogin/secret-updatedevents; seesrc/AUTH.md). Shape:"modules": { "oidc-client-ts": { "permaLoad": true,"contexts": {"core": { // "" / null / "core" → main identity"oidc": { "authority": "...", "client_id": "...", "scope": "..." },"authMethod": "redirect", // "redirect" | "popup" — the INTERACTIVE flow"tokenForServer": "access_token", // or "id_token""serviceName": "...", "usesStore": "default"// "isMain": true // implied for "core"// "useCallbackPage": true // popup/silent land on a bare page, not the viewer// "autoLogin": false // declare WITHOUT the boot login// "maxRetryCount": 2 // failed attempts before giving up// "retryTimeout": 20 // seconds shown on the retry toast// "extraSigninRequestArgs": { ... } // IdP extras (acr_values, login_hint, …)}}}}}autoLoginsays whether a context logs in at boot,authMethodsays which interactive flow it uses when it needs one — see Boot login: silent first below for what each combination does. Bounds on the silent attempt come from the library keysilentRequestTimeoutInSecondsinside theoidcblock; there is no separate xOpat knob. A legacy bare top-leveloidcblock (+method) is accepted as thecorecontext for back-compat (only whencontextsis absent — the two cannot be mixed).OIDCAuthClient.init()processes a returning callback and arms the renew loop; the boot login itself is driven by core (XOpatAuth.runAutoLogin), so a declaredcorecontext still logs the user in at boot. - Broker registration (
auth-broker.js): registers"oidc"intoAPPLICATION_CONTEXT.auth. A feature may ALSO declare a (sub-)context in code — e.g. inpluginReady— and then gate on it:await APPLICATION_CONTEXT.auth.configureContext({contextId: "anthropic", // XOpatUser sub-context + token key + verifier idmethod: "oidc",config: { authority, client_id, scope }, // the OIDC block (see below)serviceName: "Anthropic Chat",authMethod: "popup", // "popup" (default) | "redirect"tokenForServer: "id_token" // which token the server verifies});if (!APPLICATION_CONTEXT.auth.isAuthenticated("anthropic")) {await APPLICATION_CONTEXT.auth.login("anthropic"); // interactive} - One
OIDCAuthClientper context (oidc-auth.js), each with its own authority/client_id/scope. These are sub-contexts (updateXOpatUser: false) — not the main viewer identity.
Boot login: silent first
The ladder itself is core's (src/AUTH.md → Core drives the automatic login); this
broker supplies the mechanism: loginSilent = signinSilent(),
canLoginWithoutGesture = navigatesOnLogin = authMethod === "redirect".
signinSilent() returns "unknown" rather than false when it could not reach
the identity provider at all, so core declines to escalate a network blip into a
redirect that would land the user on a browser error page. login() returns a
verdict (false for a closed popup or a refused exchange), so core stops holding
the recovery scrim instead of waiting out its five-minute interactive timeout.
What an autoLogin context actually does at boot:
authMethod | boot attempt | if it does not authenticate |
|---|---|---|
"redirect" (default for autoLogin) | silent first, then a full-page redirect | the IdP page takes over |
"popup" | silent only | reports to the interaction gate — the user signs in from the scrim or the app-bar user menu |
Silent means one of two very different things, and the difference matters:
- with a refresh token → a token-endpoint call. Cheap, no frame, repeatable.
- without one → a hidden
prompt=noneiframe. This is a probe of the identity provider's own session (established by an embedding page, another tab, an earlier visit) — there is nothing of ours to renew. It rides the IdP's cookies in a third-party context, so it fails wherever those are blocked.
The probe runs at most once per session. Its answer cannot change until
something else signs the user in, and each attempt costs a watchdog timeout plus
three IdP redirects. A landed credential re-arms it (SILENT_PROBE_ONCE_PER_SESSION,
_silentSignIn). Concurrent callers — a burst of 401s, boot plus a slide — share one
in-flight attempt. Core adds a second, broker-independent bound on the 401 path
(XOpatUser.REFRESH_COOLDOWN_MS / MAX_REFRESH_FAILURES).
Regression signature to watch for: repeated …/oidc/authorize?…&prompt=none requests
minutes apart in one session, each ending in a redirect bounce or an aborted request.
Only the refresh-token route reports "unknown". The two routes fail in ways that
mean opposite things, so _silentSignIn tags its rejection with the one it took
(xopatSilentPath) and signInSilent reads that tag. A token-endpoint failure is real
evidence the authority is unreachable, and core must not escalate it into a redirect. A
frame timeout is not: the frame carries the viewer's own load cost, the provider may
simply refuse to be framed, and a redirect_uri it does not recognise makes it render
its own error page — unreadable cross-origin, so the only symptom is the watchdog. That
path reports false, which keeps the context in core's interactive phase.
Because that error is invisible, the client logs its three effective redirect URIs
(redirect_uri, popup_redirect_uri, silent_redirect_uri) at console.debug on
construction, and names silent_redirect_uri again in the frame-timeout warning. A
silent probe that always times out while the interactive login works is almost always
an unregistered URI — compare those lines against the provider's registered list
first. Registration is exact: scheme, host, path and case, with no trailing-slash
tolerance, and a provider fronted by a registry (Perun → MITREid) may need time to
sync an approved change before the live client accepts it.
Callbacks must not boot the viewer
silent_redirect_uri and popup_redirect_uri fall back to redirect_uri, which
defaults to the bare page URL — so both the library's prompt=none frame and the
sign-in popup load the whole application: plugins, tile sources, slide metadata.
In the frame that routinely outruns the 10 s watchdog
(silentRequestTimeoutInSeconds), and the resulting ErrorTimeout used to be
reported as "your session expired" while the token in hand was perfectly valid; in
the popup the user watched a second viewer boot and disappear.
OIDCAuthClient._doInit therefore answers such a callback and stops before booting
(_handleForeignAuthCallback). The detector is the window relationship, never
storage: an opener means we are the sign-in popup, a parent means we are the
silent frame, and a top-level document with neither owns its own si:r redirect and
falls through to the normal path. A legitimately embedded viewer completing its
own redirect login is therefore never mistaken for a callback.
This used to read the stored
request_typeout of the sign-in state store, and for the popup that can never work. The library creates the window before it writes the state entry (PopupWindow's constructor runswindow.openinsideprepare(); only the following_signinStartcallsstateStore.set), andsessionStorageis snapshot-cloned into a new browsing context atwindow.open()time — so the clone predates the write and the lookup missed every time. The popup booted a whole viewer and then reported "site storage is blocked" on a perfectly healthy origin, naming whichever context happened to ask. Moving the store elsewhere would not have fixed it: no browser store is guaranteed to cross a window boundary, and the candidates (kv:cache,kv:cookies) are operator-rebindable.oidc-server-tsandsaml-authalready answer this fromwindow.openerin their server-rendered callbacks; this is the same rule.
Symptoms that this regressed: console lines whose page URL carries ?state=…
(a second application booting), [Intervention] … beforeunload from IFrameWindow,
ErrorTimeout right after a successful login.
useCallbackPage — don't load the viewer at all
Detection stops the boot early, but the popup still fetches core and every module
script first. Set "useCallbackPage": true on a context to point
popup_redirect_uri and silent_redirect_uri at auth-callback.html, a document
that loads only the OIDC library and forwards the result — no loader, no plugins, no
config fetch. redirect_uri (the full-page flow) always stays the viewer page.
Off by default, because it is a deployment change. An identity provider matches redirect URIs exactly, so register this URL there before enabling it:
<viewer-origin>/modules/oidc-client-ts/auth-callback.html
Otherwise the authorize request is refused with redirect_uri_mismatch — surfaced
with the exact URL to register when the IdP redirects the error back, though some
(Google) render their own error page and never return. An explicit
popup_redirect_uri/silent_redirect_uri in the context's oidc block wins over
this flag.
One window per context
window.open with a named target reuses an existing window of that name, so the
sign-in window is named per context (xopat-auth-<ctx>, mirroring oidc-server-ts's
xopat-oidc-<ctx>). A single shared name meant a second context — or a retry after
an abandoned attempt — navigated a tab that was already open instead of opening its
own, which reads as the flow silently switching from popup to redirect.
Related knobs, both passed straight through from the per-context oidc block:
silentRequestTimeoutInSeconds (library default 10) and
accessTokenExpiringNotificationTimeInSeconds (default 60 — if it is ≥ the token
lifetime the library clamps the renew timer to 1 s and renews continuously;
_tuneRenewWindow warns with the exact value to set). A deployment that prefers a
dedicated callback document can set silent_redirect_uri explicitly — but it must be
registered at the IdP verbatim, or the renew fails with redirect_uri_mismatch.
Failure classification: report ≠ expire
-
IdP verdicts (
interaction_required,login_required,consent_required,account_selection_required) mean a human is needed. -
Timeouts (
ErrorTimeout, "IFrame timed out", "Network timed out", "Failed to fetch") mean the answer never arrived. They are retried, never treated as a verdict. -
Either way the module reports to
APPLICATION_CONTEXT.auth.markNeedsInteractionwithoutforce, so core defers while the credential still works and acts only once it actually stops working (seesrc/AUTH.md). A renew failure never tears down a working session. -
A redirect that comes back with
?error=interaction_required— the expected answer to an automaticprompt=noneattempt — triggers one real interactive login for anautoLogincontext. The guard is a store flag (xopat.interactive-retry.<ctx>), not a URL marker, becauseredirect_urimust match the IdP registration verbatim; it is released whenever a credential lands.Superseded. That automatic retry is gone, and with it the
xopat.interactive-retry.<ctx>flag. It started a full-page redirect from insideinit(), which bypasses core's boot marker and its arbitration of the single page-unloading login across all brokers — in a deployment that also loadssaml-authoroidc-server-tsthat is a secondlocation.assigncancelling the first. A callback that comes backinteraction_requirednow reports to the recovery gate; core's own ladder makes the redirect on the next load, under the marker. Costs one click in the narrow case where the silent frame was blocked and the answer landed top-level.
usesStore — where OIDC state lives
Every value routes through the IO pipeline; none of them touches
localStorage / sessionStorage directly. That is deliberate: in a sandboxed
iframe (opaque origin) the property read itself throws SecurityError, and the
pipeline substitutes in-memory drivers there — see
src/IO_PIPELINE.md.
| value | capability | outlives the tab? |
|---|---|---|
"default" / "session" | kv:session (owner module.oidc-client-ts) | no — survives a login redirect, dies with the tab |
"local" / "cache" | kv:cache (owner core) | yes, when localStorage is available |
"cookie" | kv:cookies (owner core) | yes, when cookies are available |
"local" used to mean the bare localStorage root; it is now namespaced under
the owner uid, so deployments that set it re-login once. Storage that is
unavailable degrades to memory rather than throwing — auth still works, it just
does not survive a reload.
In a sandboxed frame OIDC login cannot complete (redirect and popup flows both need a real origin), but nothing here throws at load: the client configures its stores and the feature simply reports "not authenticated".
Multiple contexts (two IdPs, or one IdP twice)
Declare as many contexts.<ctx> entries as you need — one client is built per
context, with its own authority, client_id, scope and its own namespaced storage
(oidc.<ctx>. in sessionStorage). One rule governs the shape:
At most one context may log in at boot. The boot flow is a full-page redirect: it unloads the page, so a second one issued in the same tick simply cancels the first. Give exactly one context (normally
core) the boot login and make every other one on-demand.
Core enforces that now (XOpatAuth.runAutoLogin), which is the only place that can:
this module could only ever see its own contexts, so a deployment running it
alongside saml-auth or oidc-server-ts had two brokers each guarding half the set
and nothing guarding the whole. A demoted context stays configured and logs in on
demand, with a console.error naming it.
Defaults already encode this: the main context auto-logs-in unless you set
"autoLogin": false, while a sub-context is on-demand unless you set
"autoLogin": true. A sub-context therefore defaults to the popup flow, which is
what an on-demand login needs — popups are blocked unless opened from a real click.
"contexts": {
"core": { "oidc": { "authority": "https://idp-a/…", "client_id": "viewer", "scope": "openid email" } },
"archive": { "oidc": { "authority": "https://idp-b/…", "client_id": "archive", "scope": "openid" },
"serviceName": "Slide archive" } // on-demand by default
}
archive is registered at boot but stays logged out. Log it in from a click:
if (!APPLICATION_CONTEXT.auth.isAuthenticated("archive")) {
await APPLICATION_CONTEXT.auth.login("archive"); // popup
}
Nothing prompts automatically on first use: an HttpClient bound to a context only
waits for it to settle and then sends the request unauthenticated, and the 401
refresh path attempts a silent renew only. A sub-context needs a UI affordance —
see the chat panel's Login button for the worked pattern.
If two contexts both ask for a boot redirect, the broker keeps the main one, demotes
the rest to on-demand and logs a console.error naming them. They remain fully
usable via auth.login(...).
Give each context its own client_id. The library keys its user store by
user:<authority>:<client_id>, so two contexts sharing both would share one stored
session regardless of the per-context prefix.
- Flows:
authMethod: "popup"(default; opens a new tab, keeps the workspace) or"redirect"(full-page).login()resolves viaXOpatUserevents, not the broker promise, because a redirect unloads the page — completion is detected here and on reload. redirect_uri: if not set, defaults to the current page URL stripped of?query/#hash(origin + pathname);popup_redirect_uridefaults to it. So the URL you register with the IdP is the page the viewer loads at (e.g.http://localhost:9000/). Setredirect_uriexplicitly to pin it.- Server-side verification (
register.server.ts): registers the"oidc"RS256/JWKS verifier for RPC and proxy — incoming Bearer tokens are checked against the IdP JWKS. Core stays auth-agnostic; the verifier ships with this module and is mounted once at boot (loadServerExtensions).
Configuration
1. The client OIDC block (per context)
The oidc block inside a contexts.<ctx> entry (see Behavior), or passed as
config to configureContext when a feature declares a context in code:
"oidc": {
"authority": "https://accounts.google.com", // IdP base (issuer)
"client_id": "<oauth-client-id>",
"scope": "openid email profile",
// "redirect_uri": "http://localhost:9000/", // optional — pin instead of page URL
"confidential": false // must be false; a secret warns
}
Register with the IdP (Google Console → Authorized redirect URIs): the
redirect URI = the page URL the viewer loads at (or your explicit
redirect_uri), and add the origin under Authorized JavaScript origins.
2. The server verifier (per context)
Under core.server.secure.rpcVerifiers.<contextId> (and/or proxies.<alias>.auth):
"rpcVerifiers": {
"anthropic": {
"verifiers": { "oidc": {
"jwksUri": "https://www.googleapis.com/oauth2/v3/certs",
"issuer": "https://accounts.google.com",
"audience": "<client_id>"
} },
"mode": "all"
}
}
Which token — tokenForServer
tokenForServer selects the token stored as the XOpatUser "jwt" secret (default
"access_token"). Pick it by who consumes the token — an upstream API called
directly (→ access_token, and add the API's scope) vs. our own RS256/JWKS
verifier (→ a JWT; Google's is the id_token). The full decision rule + pitfalls
are in src/AUTH.md
— e.g. DICOM against Google Healthcare needs access_token +
.../auth/cloud-healthcare in scope, or it 401s after login.
Security
Auth/OIDC config is deployment-trusted — read it with getStaticMeta
(ENV/include.json), never getOption (session/third-party controllable). Never
put a client_secret here (it would ship to the browser). See AGENTS.md §3/§7
and src/AUTH.md.