HTTP Client & Proxy – Developer Guide
This document explains:
- How to use the HttpClient in the viewer.
- How to configure and use the generic proxy on the server.
- How authentication between client ↔ proxy ↔ upstream is wired.
It’s written so you can drop it into your repo as README-http-client-proxy.md.
1. HttpClient overview
HttpClient is the standard way to make HTTP requests from the viewer code.
It supports:
- Normal requests to absolute or relative URLs.
- Requests via a server-side proxy (to hide API keys).
- Pluggable auth handlers (JWT, basic, …) that add headers based on secrets stored in
XOpatUser. - Automatic CSRF header injection for proxied requests.
Typical usage:
-
Construct a client:
const client = new HttpClient({ baseURL: "/api", auth: { contextId: "core", types: ["jwt"], required: true, }, });
-
Send a request:
const result = await client.request("user/info", { method: "GET", });
2. HttpClient constructor options
You create a client like:
const client = new HttpClient(options);
Available options:
-
baseURL(string, optional)
Prefix added in front ofpathyou pass to.request().
Ifpathis relative, the final URL isbaseURL + "/" + path. -
proxy(string, optional)
Name of the server proxy alias (e.g."openai","cerit"). When set:- All requests go through
/proxy/<alias>/<path>. - CSRF header is added automatically.
- All requests go through
-
auth(object, optional)
Controls how auth headers are added from secrets:{ contextId: "openai", // which XOpatUser context to read secrets from types: undefined, // omit: resolved from the context at request time handlers: {}, // custom handlers (rarely needed) refreshOn401: true, // whether to trigger secret refresh on authn failure refreshOnStatuses: undefined, // which statuses that means; defaults to [401] required: false, // warn when no secret is found; also defaults awaitContext awaitContext: undefined, // wait for the context to authenticate; defaults to
requiredawaitContextTimeoutMs: 8000, }-
contextId
The context under which secrets are stored inXOpatUser.
This must match what your OIDC client uses, e.g."openai","cerit-io". -
types
List of auth types to apply, in order. For each type:- The client looks up a secret via
XOpatUser.getSecret(type, contextId). - If found, it runs the corresponding handler to get headers.
Just omit it when you pass a
contextId. Types are resolved at request time fromAPPLICATION_CONTEXT.auth.getSecretTypes(contextId), so the auth module owning the context decides (secretTypes) and a client constructed before that context was configured still follows it.["jwt"]remains the fallback for a context nobody has configured. Pass an explicit list only to override the owning module. Note these are client secret types — the server verifier names (jwt,oidc,saml, …) are a separate namespace, linked only bycontextId. - The client looks up a secret via
-
handlers
Optional map of custom auth handlers. By default,HttpClienthas global auth handlers registered (e.g."jwt"). You can override or extend them. -
refreshOn401
Iftrue, and a request returns 401, the client will fire arequestSecretUpdateevent so other code (e.g. OIDC auth client) can refresh the token. The refresh rejects immediately when no auth module listens forsecret-needs-updateon that context, instead of sitting on a 20 s timer.The 401 is first reported to
XOpatUser.reportSecretRejectedwith the credential that was attached, and a subsequent OK callsreportSecretAccepted. That pair is what bounds the case a refresh budget cannot: a provider that keeps issuing fresh tokens the server keeps refusing. AfterMAX_REFRESH_FAILURESdistinct rejected credentials the context is handed to the interactive-recovery gate and further refreshes are refused. Seesrc/AUTH.md→ "…and refreshes that SUCCEED are bounded too".A request that carried no credential at all is not treated as "already superseded": the client asks the provider for every type the context declares. A context that lost its secret (an identity swap drops them — see
src/AUTH.md) or never received one can therefore recover, instead of retrying bare forever.XOpatUser.requestSecretUpdateis budgeted per secret, so a provider that cannot deliver is asked twice, not once per request. -
refreshOnStatuses
Which HTTP statuses count as an authn failure for that refresh. Default[401];refreshOn401: falsestill disables the mechanism whatever this lists.Widen it only for an upstream that reports a missing credential with something else. FastAPI's
HTTPBeareris the case in the tree: it answers 403 when theAuthorizationheader is absent and 401 only once the header is present and rejected — somodules/empaia-workbenchpassesrefreshOnStatuses: [401, 403], without which a context whose token went missing is served 403s that nothing retries and the session stays dead. Keep the list narrow: a listed status spends an identity-provider round trip per failing burst, so it must mean "your credential is wrong or missing", never "you are authenticated but not allowed".A 401 that is an xOpat session error (
RPC_NO_SESSION/RPC_BAD_CSRF, or a "missing or invalid session" body) never reaches this path at all — it is routed toXOpatSessionRecovery, because refreshing an identity-provider token cannot revive a dead server session. That detection applies to any same-origin target, not just proxied ones:/__rpc/...calls travel on clients built with a barebaseURLand no proxy alias. -
required
Iftrueand no secret was found,_authHeaderswarns once per context (proxied or not):XOpatRemoteEndpoint: auth.required=true but no secret is available for context 'core'…
It also turns on
awaitContextby default. -
awaitContext/awaitContextTimeoutMs
Before issuing a request for which no secret exists yet, wait (bounded) for the auth context to finish authenticating —APPLICATION_CONTEXT.auth.whenContextSettled. This is what stops the boot request burst from racing an asynchronous login (OIDC redirect return, silent renew) and 401-ing. The wait is resolved before the request timeout is armed, honours the caller'sAbortSignal, and never throws: if it fails the request is sent unauthenticated so the upstream's own 401 (with its diagnostics) is what surfaces. Complementary torefreshOn401, which covers expiry rather than not-logged-in-yet.Set it to
falseon any client an auth broker itself uses to obtain a credential for the same context — it would otherwise wait on its own work. Seesrc/AUTH.md→ "Waiting for a context to settle".
-
-
secretStore(optional)
Object withgetSecret(type, contextId)andsetSecret(...). Defaults toXOpatUser.instance(). -
timeoutMs(number, optional, default30000) Per-request timeout in milliseconds. Implemented viaAbortControllerinHttpClient.request. -
maxRetries(number, optional, default3) Number of automatic retries on429and5xxresponses. Set to0to disable retries.The status is only a heuristic, and an error body may overrule it. A JSON error payload carrying
"retriable": falseis never replayed, at any status;"retriable": trueis always replayed. This exists because our own RPC layer answers500for every handler throw, so the status cannot distinguish an overloaded gateway from an upstream401relayed through it — the server-side thrower sets the flag (seeserver/node/README.md, the error-contract table). Absent the flag:429retries,5xxretries except504+code: "RPC_TIMEOUT", everything else does not.
3. Making requests
Call:
const result = await client.request(path, options);
Where:
-
path(string)
Path relative tobaseURL(if set). For proxy mode, this is the path after/proxy/<alias>/. -
options(object){ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", // default "GET" headers: { ... }, // extra headers body: any, // will be JSON.stringify’d if object query: { ... }, // optional query string object — appended via URLSearchParams; arrays become repeated keys expect: "json" | "text" | "auto", // default "auto" — drives response parsing }
expect in detail:
-
"json"—res.json(). A body that does not parse throws anHTTPError(not a bareSyntaxError), so it surfaces immediately instead of being replayed by the retry arm. -
"text"—res.text(), verbatim. -
"auto"(default) — a JSON content-type parses as JSON; otherwise the body is read once, parsed as JSON opportunistically, and returned as text when that fails. An empty body returns{}.An HTML document is refused, never returned. If the content-type is
text/html/application/xhtml+xml, or the body sniffs as<!doctype html/<html, the call throws anHTTPErrorcarrying a capped excerpt. What answerstext/htmlto an API call is an intermediary — a proxy error page, a captive portal, a WAF block, a login redirect — and returning that markup as "the result" hands third-party-authored HTML to a caller that may render or interpolate it (AGENTS.md §7). Useexpect: "text"for an endpoint that genuinely serves documents.
Failed responses (non-2xx) have at most 16 KiB of their body read into
HTTPError.textData; longer bodies are suffixed … [truncated]. A server's
{"retriable": …} verdict is honoured only from our own origin — a
cross-origin upstream cannot dictate this client's retry policy.
Example:
const client = new HttpClient({
baseURL: "/api",
});
const data = await client.request("items", {
method: "GET",
query: { page: 1, pageSize: 20 },
});
const created = await client.request("items", {
method: "POST",
body: { name: "New item" }, // will be sent as JSON
});
4. Auth handlers in detail
4.1. Secrets in XOpatUser
HttpClient relies on secretStore (by default XOpatUser) to obtain credentials:
XOpatUser.setSecret(secretValue, type, contextId)XOpatUser.getSecret(type, contextId)
Typically, your OIDC auth client will:
- Log the user in.
- Store tokens in
XOpatUserwithtype = "jwt"andcontextId = "openai"(or similar).
4.2. Global auth handlers
HttpClient has a static registry of handlers, inherited from XOpatRemoteEndpoint:
HttpClient.registerAuthHandler("name", handlerFn)HttpClient.knowsSecretType("name")— whether anything can turn that secret into headers
A handler has the form:
async function myHandler({ secret, type, contextId, url, method }) {
return {
"Authorization": "Bearer " + secret,
};
}
Two handlers ship by default:
"jwt"— takes the JWT secret fromXOpatUser, addsAuthorization: Bearer <jwt>."basic"— takes a{username, password}secret and addsAuthorization: Basic base64(user:pass). It returns{}when no secret (or nousername) is stored, so it is inert until something provides a credential.
Which types a request actually uses comes from the context, not from a hardcoded
list: auth.types if you passed it, else APPLICATION_CONTEXT.auth.getSecretTypes(contextId),
else ["jwt"]. A broker declares secretTypes when it configures its context —
modules/basic-auth declares ["basic"] — and every consumer follows with no
code change.
Basic auth: the handler is only half the story. A credential source must store the secret. Load
modules/basic-authfor a per-user login prompt, or — preferably, when the credential is per-deployment rather than per-user — inject it server-side viaserver.secure.proxies.<alias>.headersso it never reaches the browser at all. Seemodules/basic-auth/README.md.
4.3. Auth flow inside _authHeaders
When a request is sent:
- Cross-origin URLs (an absolute URL outside
baseURL's origin) drop all auth headers, with one warning per foreign origin. - If
awaitContextis on and no secret exists yet, the client waits (bounded, abortable) forAPPLICATION_CONTEXT.auth.whenContextSettled(contextId). _authHeadersiterates the resolved secret types (explicitauth.types, elsegetSecretTypes(contextId), else["jwt"]).- For each type:
- Looks up a secret
getSecret(type, contextId). - If found, calls the handler with
{ secret, type, contextId, url, method }. - Merges the returned headers into the request.
- Looks up a secret
- If
requiredistrueand no secret was found for any type, one warning per context is logged and the request goes out unauthenticated.
The proxy/login enforcement is ultimately done server-side; the client just controls whether it tries to send tokens and warns if it can’t.
5. Proxy mode in HttpClient
You enable proxy mode by passing a proxy string:
const client = new HttpClient({
proxy: "cerit",
baseURL: "/v1/chat/completions",
auth: {
contextId: "cerit-io",
types: ["jwt"],
required: true,
},
});
Behavior in proxy mode:
-
All requests are made to:
/proxy/<alias>/<baseURL>/<path>?...
For example:
/proxy/cerit/v1/chat/completions
baseURLhere is a path, not an origin: the alias already names the upstream server-side (core.server.secure.proxies.<alias>.baseUrl). An absolutebaseURLpassed alongside a proxy is reduced to its path and warned about — it used to be concatenated as given, producing/proxy/<alias>/http://host:port/..., a URL no server can answer. The trailing slash of a request path is forwarded as written, because upstreams distinguish/v3/cases/from/v3/cases. -
HttpClient automatically adds CSRF header if
window.XOPAT_CSRF_TOKENis available:X-XOPAT-CSRF:
If the token is missing, a warning is logged.
-
It also adds X-XOPAT-Session if
window.XOPAT_SESSION_IDis available. That global exists only undercore.server.security.cookielessSessions, i.e. when the viewer is embedded in a third-party page and may have no cookie jar at all — third-party cookies blocked, or asandboxiframe withoutallow-same-origin. The server then accepts the header in place of the session cookie (the CSRF check is unchanged, and the cookie still wins when both arrive). Outside that mode the global is absent and nothing extra is sent. See Embedding the viewer in a third-party page. -
Credentials mode is set appropriately (e.g.
credentials: "same-origin") so cookies and CSRF protection work as expected.
6. Server-side proxy basics
On the server, there is a generic /proxy/<alias>/... handler that:
- Reads the viewer configuration (
core.CORE.server.secure.proxies). - Finds the proxy config for
alias. - Optionally runs auth verifiers.
- Forwards the request to the configured
baseUrl+targetPath. - Merges static
headers(e.g. API keys) from the config.
6.1. Proxy configuration
In your server config (e.g. config.json):
"server": {
"secure": {
"proxies": {
"cerit": {
"baseUrl": "https://llm.ai.e-infra.cz/v1/",
"headers": {
"Authorization": "Bearer <CERIT_API_KEY>"
},
"auth": {
"enabled": true,
"verifiers": ["jwt"],
"mode": "all",
"jwt": {
"secret": "<% VIEWER_JWT_SECRET %>",
"issuer": "https://login.example.com/",
"audience": "xopat-viewer",
"forward": false,
"userClaimHeader": "x-user-sub"
}
}
}
}
}
}
Fields:
-
baseUrl
The upstream base URL to forward to (e.g. CERIT, OpenAI, internal gateway). -
headers
Static headers always added to upstream requests (API keys, custom headers). -
auth.enabled(boolean)
Whether viewer-level auth should be enforced for this proxy. -
auth.verifiers(object map, or array of strings)
Which verifiers must run. Two accepted shapes:"verifiers": { "jwt": { "secret": "<% VIEWER_JWT_SECRET %>" } } // preferred"verifiers": ["jwt"] // shorthandThe map form is preferred and is what the rest of the docs use: it is the only one that can carry per-verifier configuration. The array form is shorthand for "these verifiers, with empty config", and then the settings must come from the sibling block (
auth.jwtbelow). Both are normalized identically on every backend —getVerifierEntriesinserver/node/auth.js.Note this is the proxy
authblock. RPC uses a separateserver.secure.rpcVerifierssection with the same two shapes — seeAUTH.md. -
auth.mode("all"or"any")"all": all listed verifiers must pass."any": at least one must pass.
-
auth.jwt(object, optional)
Per-proxy JWT settings (see below).
7. Proxy auth verifiers
The server has a small framework for verifiers:
-
Registry:
registerProxyAuthVerifier("name", async ({ req, res, core, alias, proxyConfig, upstream }) => { // throw or return false to fail // mutate upstream.headers as needed return true; });
-
Main function:
await verifyProxyAuth(req, res, core, alias, proxyConfig, upstreamState);
upstreamState is:
{
headers: { ... }, // mutable headers object to send upstream
targetPath: string, // e.g. "/v1/chat/completions"
}
Verifiers can:
- Inspect the request (
req.headers,req.user, etc.). - Validate tokens or other credentials.
- Add or remove headers in
upstream.headersbefore the request is sent to the upstream service.
If auth fails, verifyProxyAuth sends 401 Unauthorized and the proxy stops.
8. JWT verifier (HS256)
Server support. The JWT verifier described here runs as a proxy verifier on both the Node and PHP servers (the two implementations are kept at parity). RPC verifiers (§7's
rpcVerifiers, used to gate/__rpc/...) are Node-only — the PHP server has no RPC endpoint, so on PHP, JWT applies to the proxy only. See Generic Deployment → PHP server.
There is a built-in "jwt" verifier that:
- Extracts
Authorization: Bearer <token>from the request. - Parses the JWT (header, payload, signature).
- Verifies that:
header.alg === "HS256",header.typ === "JWT".- Signature matches using the configured secret.
exphas not passed,nbf(if present) is valid.issandaudmatch configured values (if set).
Configuration sources:
- Global:
core.CORE.server.auth.jwt - Per-proxy:
proxyConfig.auth.jwt(overrides global)
Example JWT config:
"server": {
"auth": {
"jwt": {
"secret": "<% VIEWER_JWT_SECRET %>",
"issuer": "https://login.example.com/",
"audience": "xopat-viewer",
"clockSkewSec": 60,
"forward": false,
"userClaimHeader": "x-user-sub"
}
}
}
Per-proxy can override specific keys:
"server": {
"secure": {
"proxies": {
"cerit": {
"auth": {
"enabled": true,
"verifiers": ["jwt"],
"mode": "all",
"jwt": {
"forward": false,
"userClaimHeader": "x-user-sub"
}
}
}
}
}
}
Behavior after verification:
-
If valid, sets
req.user = payload(decoded JWT claims). -
If
jwtCfg.forward !== true, removesAuthorizationfromupstream.headersso the upstream service does not see the viewer’s JWT. -
If
jwtCfg.userClaimHeaderis set andpayload.subexists, adds:upstream.headers[jwtCfg.userClaimHeader.toLowerCase()] = payload.sub;
Thus, the upstream can see the user identity via a custom header, but not the full JWT.
9. Client ⇄ Proxy auth alignment
To make everything coherent:
-
For a proxy that requires viewer auth:
- Set
auth.enabled: true+verifiers: ["jwt"]on the server. - On the client, construct
HttpClientwith:proxy: "<alias>",auth.contextIdset to your OIDC context,auth.types: ["jwt"],auth.required: true.
- Set
-
For a proxy that uses only API keys, no viewer auth:
- Set
auth.enabled: false(or omitauth) on the server. - On the client, use:
proxy: "<alias>",autheither omitted orrequired: falseandtypes: [].
- Set
This way:
- Server is the ultimate gatekeeper (rejects unauthenticated requests).
- Client only controls whether it tries to send auth headers and logs helpful warnings when misconfigured.
10. Summary
- Use
HttpClientfor all viewer-side HTTP. - Use
proxywhen talking to external APIs (LLMs, cloud services) so secrets stay on the server. - Configure
authin both:- viewer (what headers to send),
- server (what verifiers to run and how to forward to upstream).
- The JWT verifier ensures that:
- viewer tokens are valid,
- upstream only sees what it needs (API keys + optional user ID header),
- headers can be cleaned/reshaped per proxy.
With this setup, you have a flexible, secure, and configurable pipeline for LLMs and other external services that can evolve to support additional auth methods simply by registering new verifiers.