Skip to content

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:

  1. Discover AuthIssuer and TokenExchangeEndpoint from /api/info.
  2. Run OIDC discovery on AuthIssuer, then do a standard PKCE authorization-code login. Request the organization scope so the IdP emits the org claim the exchange needs:

    scope=openid email profile organization
    code_challenge_method=S256
    
  3. 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_token
    

    The response is a JSON object containing the minted access_token (and the resolved organization_id).

  4. 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.com and https://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, in sessionStorage.
  • 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.