Custom UI Apps
ConfigHub makes it easy to build your own web UIs for any purpose you can think of. They are single-page applications (SPAs) that authenticate a user through ConfigHub's identity provider. After authentication, they perform a token exchange with ConfigHub itself and then treat ConfigHub as a resource server.
The Quick Start gets a working app running against your instance using the ConfigHub JavaScript SDK. The Deep Dive explains what the SDK does under the hood, so you can build on another framework or understand the auth flow in detail.
Quick Start
ConfigHub ships a JavaScript SDK for browser apps — github.com/confighub/js-sdk. It has three packages:
@confighub/react-auth— a React provider and hooks that run the whole login flow and hand your app a typed API client with the token already attached.@confighub/api— that typed client on its own: framework-agnostic, generated from the ConfigHub OpenAPI spec (built on openapi-fetch; no React, no Redux).@confighub/rtk-query— the same API as generated RTK Query hooks, if your app is already on Redux Toolkit.
With @confighub/react-auth, a working app is three steps.
1. Register an OAuth client
Register your app and capture its client_id:
export CLIENT_ID=$(cub oauthclient create my-ui \
--redirect-uri http://localhost:5173/ -o jq='.ClientID')
A client_id is public, not a secret. Register every origin the app is served
from — see Registering a client.
Register in your users' organization
Create the client in the same organization as your app's users. An app can
only sign in members of the organization that owns its client_id — this is
what stops one org's app from capturing another org's users. Log in with cub
to that organization before running create.
Serving users from many organizations through a single app is supported only for trusted app providers. If you want to offer one, contact hello@confighub.com.
2. Wrap your app in the auth provider
npm install @confighub/react-auth @confighub/api
import { ConfigHubAuthProvider, useAuth, useConfigHub } from '@confighub/react-auth';
function Root() {
return (
<ConfigHubAuthProvider baseUrl="https://hub.confighub.com" clientId={CLIENT_ID}>
<App />
</ConfigHubAuthProvider>
);
}
function App() {
const { status, user, login, logout } = useAuth();
const api = useConfigHub(); // typed client, token already attached
if (status === 'loading') return <p>…</p>;
if (status !== 'authenticated') return <button onClick={login}>Log in</button>;
// Calls are fully typed against the pinned ConfigHub spec:
// await api.GET('/me')
// await api.GET('/space/{space_id}/unit', { params: { path: { space_id } } })
return <button onClick={logout}>Sign out — org {user!.organizationId}</button>;
}
baseUrl and clientId are the only configuration. The issuer and OIDC endpoints
are discovered from {baseUrl}/api/info at runtime, so the same build runs against
any ConfigHub instance.
3. Run it
Serve the app on an origin you registered as a redirect URI and click Log in.
The provider runs the login flow and, once you are authenticated, useConfigHub()
returns a client whose calls carry your minted ConfigHub token.
The minted token lives in memory only, so a full page refresh currently returns you to the login screen — a deliberate default covered in Security posture.
See a complete example
examples/space-browser in the SDK repo is a full app (~200 lines) built on
both packages — it logs you in, lists your org's spaces, and drills into a
space's units. To run it:
git clone https://github.com/confighub/js-sdk && cd js-sdk && npm install
cub oauthclient create space-browser --redirect-uri http://localhost:5173/
cp examples/space-browser/.env.example examples/space-browser/.env
# edit that .env: set VITE_OAUTH_CLIENT_ID to the client_id above
npm run example # vite dev server on http://localhost:5173
Not using React?
Use @confighub/api directly and give it a token source:
import { createConfigHubClient } from '@confighub/api';
const api = createConfigHubClient({
baseUrl: 'https://hub.confighub.com',
getToken: () => session.accessToken, // sets Authorization: Bearer
});
const { data, error } = await api.GET('/space/{space_id}/unit', {
params: { path: { space_id } },
});
The client never stores or refreshes tokens — you supply getToken. The
Deep Dive shows how to obtain a token without the React provider.
Already on Redux Toolkit?
Use @confighub/rtk-query for generated hooks (useListUnitsQuery, …) with caching
and cache invalidation, while @confighub/react-auth still drives login. Configure
the API once — its token source is react-auth's non-React getAccessToken accessor —
then mount its reducer and middleware in your store:
import { configureConfigHub, confighubApi } from '@confighub/rtk-query';
import { getAccessToken } from '@confighub/react-auth';
import { configureStore } from '@reduxjs/toolkit';
configureConfigHub({ baseUrl: 'https://hub.confighub.com', getToken: getAccessToken });
export const store = configureStore({
reducer: { [confighubApi.reducerPath]: confighubApi.reducer },
middleware: (getDefault) => getDefault().concat(confighubApi.middleware),
});
Wrap the app in both the react-redux <Provider store={store}> and
<ConfigHubAuthProvider> as usual. examples/space-browser-rtk in the SDK repo is a
full app built this way.
Deep Dive
The model
ConfigHub-the-API is an OAuth 2.0 resource server. It accepts exactly one
kind of credential on /api: a ConfigHub-minted JWT presented as a bearer token.
Your app never sends a raw identity-provider (IdP) token to /api.
Your app is a standard OIDC client of the issuer ConfigHub names at runtime —
its Keycloak, discovered from /api/info (see Discovery).
Your app logs the user in against that issuer with OIDC + PKCE, then trades the
resulting IdP token for a ConfigHub token via token exchange
(RFC 8693). The minted ConfigHub token is what it puts on Authorization: Bearer
when calling /api.
The app needs only two configuration values: the ConfigHub base URL and its OAuth
client_id. Everything else it discovers at runtime.
Note
Because the issuer is discovered rather than hardcoded, the same app build runs unchanged against any ConfigHub instance — only the discovered issuer URL differs.
Discovery
Fetch the discovery document from your ConfigHub instance:
GET {base}/api/info
The relevant fields are:
| Field | Meaning |
|---|---|
AuthIssuer |
The OIDC issuer your app logs in against. Run OIDC discovery on it ({AuthIssuer}/.well-known/openid-configuration) to find the authorize and token endpoints. |
TokenExchangeEndpoint |
Where your app POSTs the IdP token to mint a ConfigHub token. |
TokenExchangeAudience |
The audience this instance pins on tokens it accepts at exchange. |
If AuthIssuer is empty, the instance is not configured for custom UI app auth.
The login flow
@confighub/react-auth runs this whole flow for you. This section is for
understanding it, or implementing it on another framework or language.
Browser SPA AuthIssuer (IdP) ConfigHub (resource server)
─────────── ──────────────── ───────────────────────────
1. GET /api/info ─────────────────────────────► { AuthIssuer, TokenExchangeEndpoint }
2. OIDC PKCE: authorize + code→token ─────────► app receives an IdP token
3. POST {idp_token} ──────────────────────────► TokenExchangeEndpoint (RFC 8693)
│ validate via IdP JWKS
│ resolve org + role from claims
│ mint ConfigHub token
4. ◄──────────── { access_token (minted) } ───────┘
5. fetch /api Authorization: Bearer <minted> ► (CORS, bearer)
The steps in detail:
- Discover
AuthIssuerandTokenExchangeEndpointfrom/api/info. -
Run OIDC discovery on
AuthIssuer, then do a standard PKCE authorization-code login. Request theorganizationscope so the IdP emits the org claim the exchange needs:scope=openid email profile organization code_challenge_method=S256 -
Exchange the IdP token for a ConfigHub token at
TokenExchangeEndpoint:POST {TokenExchangeEndpoint} Content-Type: application/x-www-form-urlencoded grant_type=urn:ietf:params:oauth:grant-type:token-exchange &subject_token=<the IdP access_token> &subject_token_type=urn:ietf:params:oauth:token-type:access_tokenThe response is a JSON object containing the minted
access_token(and the resolvedorganization_id). -
Call the API with the minted token:
GET {base}/api/me Authorization: Bearer <minted access_token>
Doing it without the SDK
If you are not on React, the flow above is short enough to write by hand. This is
the heart of it (PKCE boilerplate elided); pair the resulting token with
@confighub/api (its getToken seam) for typed calls:
// 1. discover
const info = await (await fetch(`${base}/api/info`)).json();
const meta = await (await fetch(
`${info.AuthIssuer}/.well-known/openid-configuration`)).json();
// 2. PKCE authorize against meta.authorization_endpoint, then redeem the code
// at meta.token_endpoint with grant_type=authorization_code (public client,
// code_verifier, no secret) → idpToken.access_token
// 3. exchange for a ConfigHub token
const minted = await (await fetch(info.TokenExchangeEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
subject_token: idpToken.access_token,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
}),
})).json();
// 4. call the API
await fetch(`${base}/api/me`, {
headers: { Authorization: `Bearer ${minted.access_token}` },
});
Registering a client
Register your app with cub oauthclient. Registration is org-scoped: you register
into your own organization, and registration creates a public PKCE OAuth client
for your app.
What to register as the redirect URI
With the SDK the redirect URI is the exact URL your app is served at — it
redirects to its own origin + pathname (the page the provider loads on). For
an app served at the root that is the origin with a trailing slash
(https://dash.example.com/). Register that, not a /callback path — the
SDK does not use one. (A /callback URI is only right if you implement the flow
yourself and redeem the code there; see The login flow.)
# Register an app with one redirect URI
cub oauthclient create my-dashboard \
--redirect-uri https://dash.example.com/
# Multiple redirect URIs (repeat the flag) — e.g. prod plus local dev
cub oauthclient create my-dashboard \
--redirect-uri https://dash.example.com/ \
--redirect-uri http://localhost:5173/
create prints the new client's client_id — that is the value your app needs.
You can capture it directly into a shell variable:
export CLIENT_ID=$(cub oauthclient create my-dashboard \
--redirect-uri https://dash.example.com/ -o jq='.ClientID')
Manage your org's apps with:
cub oauthclient list # only your org's apps
cub oauthclient get my-dashboard # by name or client_id
cub oauthclient delete my-dashboard # apps using this client_id can no longer log in
There is no in-place update: to change an app's redirect URIs, delete the client and create a new one. Register every origin you will serve from up front.
What registration creates
The registered Keycloak client is configured for a browser SPA:
- Public client — no secret (correct for code running in a browser).
- PKCE enforced (
S256). - Standard authorization-code flow only — direct grants and service accounts are off.
- Exact redirect URIs, no wildcards. A bare origin is normalized to its root
path, so
https://dash.example.comandhttps://dash.example.com/are equivalent. An app served under a subpath must register that exact path (e.g.https://dash.example.com/console/), since the SDK redirects to its own origin and path. - CORS origins (
webOrigins) derived from your redirect URIs, so the browser can call the IdP token endpoint directly.
Note
Each organization has a default quota of 10 registered clients
(CONFIGHUB_OAUTHCLIENT_QUOTA). Trusted first-party organizations are exempt.
One org vs. many
An app is restricted to the organization that registered it: only members of that org can log in through it, and it can only ever mint sessions for that org. This is a deliberate safety boundary — an app registered by org B cannot capture an org-A user's session. So register the client in the same organization as the users you intend it to serve.
Serving users from many organizations through a single app — for example a hosted
console offered to every customer — is supported only for trusted app
providers (via the --allow-all-orgs registration option, which ConfigHub
enables per organization; a request from a non-trusted org is refused). If you
want to offer such an app, contact
hello@confighub.com.
Security posture
This path keeps tokens in the browser rather than behind a backend-for-frontend
(BFF). @confighub/react-auth implements the posture that makes that safe:
- The minted token is held in memory, never
localStorage. Only the transient PKCE verifier survives the authorize redirect, insessionStorage. - A 401 clears the session so the app re-authenticates.
In return, CSRF disappears by construction: there is no ambient cookie credential, because the API is called with a bearer token rather than a session cookie.
For the same reason, /api sets Access-Control-Allow-Origin: * — it allows calls
from any origin, so your app reaches it cross-origin with no per-origin CORS
setup. That is safe because a bearer-token API has no ambient credential another
origin could abuse: a call still fails without a valid minted token. Registering
your app's origin is about login (the redirect URIs and webOrigins above), not
about reaching /api.
Not yet implemented
Silent refresh via refresh-token rotation, and IdP end-session on logout, are
not yet in the SDK — today a 401 or logout() returns the app to the login
screen. These follow the server-side refresh-rotation work. Customers who want
HttpOnly-cookie semantics can still run a proxy (BFF) model, where a backend
holds the token and the browser talks to that backend.