@oxaigen/react
v0.1.5
Published
React SDK for Oxaigen platform auth — drop-in <AuthProvider>, useAuth hook, RequireAuth and PermissionGate components for apps deployed behind the Oxaigen proxy.
Maintainers
Readme
@oxaigen/react
React SDK for apps deployed behind the Oxaigen platform proxy. Drop in an <AuthProvider>, then gate routes with
<RequireAuth> and <PermissionGate>. No configuration — all auth traffic flows over same-origin /_oxa_auth/*
endpoints that the proxy serves on every app's hostname.
Install
yarn add @oxaigen/react
# or
npm install @oxaigen/reactPeer dependency: react >= 18.
Quick start
import { AuthProvider, RequireAuth, PermissionGate } from "@oxaigen/react";
function App() {
return (
<AuthProvider>
<Routes>
<Route path="/" element={<Home />} />
<Route
path="/dashboard"
element={
<RequireAuth>
<Dashboard />
</RequireAuth>
}
/>
<Route
path="/audit"
element={
<RequireAuth>
<PermissionGate name="audit">
<Audit />
</PermissionGate>
</RequireAuth>
}
/>
</Routes>
</AuthProvider>
);
}<PermissionGate> on its own only checks the permission — it does not trigger a sign-in flow for anonymous users (
it renders the denied slot instead). Wrap it in <RequireAuth> when you want unauthenticated visitors to be prompted
to sign in, as shown above.
The useAuth hook
import { useAuth } from "@oxaigen/react";
function Header() {
const { status, signIn, signOut } = useAuth();
if (status === "loading") return <span>…</span>;
if (status === "authenticated") {
return <button onClick={() => signOut()}>Sign out</button>;
}
return <button onClick={() => signIn()}>Sign in</button>;
}status is one of:
| value | meaning |
|-------------------|--------------------------------------------------------------|
| "loading" | Initial check in flight |
| "authenticated" | User has a valid session for this app |
| "anonymous" | User is not signed in |
| "error" | Something went wrong (e.g. server unreachable). See error. |
Reading the user profile
useAuth().user returns the user data fetched from /_oxa_auth/get-me, scoped to the current workspace and app. It's
null when loading, anonymous, or errored — and also briefly null between sign-in completing and the get-me fetch
resolving, so always gate on status first.
If a component only needs the user object, use the useUser() convenience hook:
import { useUser } from "@oxaigen/react";
function Greeting() {
const user = useUser();
if (!user) return null;
return <span>Hi, {user.email}</span>;
}Role and permission checks
The provider exposes both synchronous and async checks:
const { hasRole, hasPermission, checkPermission } = useAuth();
// Synchronous — reads from the cached User object captured at sign-in.
// Cheap, but reflects the user's state at the time get-me last ran.
if (hasRole("admin")) { /* … */
}
if (hasPermission("audit")) { /* … */
}
// Async — round-trips to the server. Use this when you need an
// authoritative answer (e.g. before a destructive action). Results
// are cached in-memory per name; the cache clears on signIn / signOut
// / refresh.
const ok = await checkPermission("audit");Use refetchPermission(name) to bypass the cache and force a fresh check.
Important: the SDK does NOT hold tokens in JavaScript
The access token lives in an HTTP-only cookie set by the Oxaigen proxy. JavaScript cannot read it. That's by design — if it could, app XSS would be able to steal it.
What this means in practice:
- Authenticated requests to your own backend just work. Use
fetchwithcredentials: "same-origin"(or"include"for CORS) and the cookie rides along. NoAuthorization: Bearer …header needed. - You don't need to refresh tokens. The proxy handles that.
- You can't decode the JWT in the browser. User details (email, name, roles, permissions) come from
/_oxa_auth/get-me, which the provider fetches automatically and exposes viauseAuth().user/useUser().
Customizing the gate UI
Both <RequireAuth> and <PermissionGate> ship with bare-bones default UI. Override with render props:
<RequireAuth
loading={<Spinner />}
unauthenticated={({ signIn, error }) => (
<Box p={6}>
<Heading size="md">Please sign in</Heading>
{error && <Alert status="error">{error}</Alert>}
<Button onClick={() => signIn()}>Sign in</Button>
</Box>
)}
>
<Dashboard />
</RequireAuth><PermissionGate
name="audit"
loading={<Spinner />}
denied={({ name }) => <AccessDenied permission={name} />}
errored={({ message, retry }) => (
<ErrorPanel message={message} onRetry={retry} />
)}
>
<AuditPanel />
</PermissionGate>Cross-origin Bearer auth
If your frontend at app.{client}.oxaigen.com needs to call a backend API on a different host (e.g.
api.{client}.oxaigen.com), the browser doesn't automatically send your access cookie there — cookies are scoped per
host.
The proxy sets the access token cookie WITHOUT the HttpOnly flag specifically so frontend JS can read it and forward
it as Authorization: Bearer .... Use the useAccessToken hook:
import { useAccessToken } from "@oxaigen/react";
function ApiClient() {
const { token, refresh, isRefreshing, error } = useAccessToken();
async function loadThings() {
const resp = await fetch("https://api.acme.oxaigen.com/things", {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
return resp.json();
}
// ...
}The hook:
- Reads the access token from
document.cookieon mount and on every auth status change. - Auto-refreshes the token ~60 seconds before its
expclaim expires. The refresh call goes to/_oxa_auth/refresh( same-origin), which uses the HttpOnly refresh cookie server-side to mint new tokens and set new cookies. - Exposes a manual
refresh()for cases where you want to force a fresh token (e.g. after a 401 from your backend API).
Security trade-off. Because the access cookie is JS-readable, any XSS in your frontend can read it. The refresh token cookie remains HttpOnly, so the attacker can't get a long-lived token — but they can use the access token until its expiry (typically 5 minutes).
If your app doesn't need cross-origin Bearer forwarding (e.g. all your APIs are same-origin behind a BFF pattern), set
ACCESS_COOKIE_HTTP_ONLY=true on the proxy at deploy time. The SDK's useAccessToken hook will then return null for
the token (cookie unreadable from JS), and you should use plain fetch(..., { credentials: "same-origin" }) instead —
the browser handles cookie attachment.
API reference
<AuthProvider>
Provides auth context to the tree. On mount it calls /_oxa_auth/test-app-token and (if authenticated)
/_oxa_auth/get-me to populate user.
| prop | type | default | description |
|--------------------|-------------|---------|----------------------------------------------------------------------|
| children | ReactNode | — | App tree |
| skipInitialCheck | boolean | false | If true, skips the test-app-token call on mount. Useful for tests. |
useAuth()
Returns the full auth context. Throws if used outside <AuthProvider>.
| field | type | description |
|---------------------------|----------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
| status | "loading" \| "authenticated" \| "anonymous" \| "error" | Current auth state. |
| user | User \| null | Current user profile, scoped to this app's workspace. Null when loading / anonymous / errored. |
| error | string \| null | Last error message. |
| signIn(returnTo?) | (returnTo?: string) => Promise<void> | Open the sign-in popup. returnTo defaults to the current URL. Resolves once the popup is opened, NOT when sign-in completes — listen to status for that. |
| signOut() | () => Promise<void> | Sign out. Cookies are cleared by the API. |
| refresh() | () => Promise<void> | Re-run test-app-token and get-me. |
| checkPermission(name) | (name: string) => Promise<boolean> | Memoized server-side permission check. Errors are treated as denial. |
| refetchPermission(name) | (name: string) => Promise<boolean> | Force a fresh server-side permission check, bypassing cache. |
| hasRole(name) | (name: string) => boolean | Synchronous role check against the cached user. Returns false if user not yet loaded. |
| hasPermission(name) | (name: string) => boolean | Synchronous permission check against the cached user. Returns false if user not yet loaded. For authoritative checks, use checkPermission. |
useUser()
Convenience hook returning just useAuth().user. Re-renders whenever the User reference changes.
<RequireAuth>
| prop | type | description |
|-------------------|-------------------------------------------------|--------------------------------------------------|
| children | ReactNode | What to render when authenticated. |
| loading | ReactNode \| () => ReactNode | Optional override for the loading state. |
| unauthenticated | ReactNode \| ({ signIn, error }) => ReactNode | Optional override for the unauthenticated state. |
<PermissionGate>
| prop | type | description |
|------------|--------------------------------------------------------|-------------------------------------------------------------|
| name | string | Permission name to check. |
| children | ReactNode | What to render when allowed. |
| loading | ReactNode \| () => ReactNode | Optional override. |
| denied | ReactNode \| ({ name }) => ReactNode | Optional override. Also rendered for unauthenticated users. |
| errored | ReactNode \| ({ message, retry, name }) => ReactNode | Optional override. |
Low-level API
For advanced use (custom providers, scripts, tests):
import {
startLogin,
testAppToken,
testAppPermission,
getMe,
logout,
} from "@oxaigen/react";Thin typed wrappers over fetch to the corresponding /_oxa_auth/* endpoints. See JSDoc for details.
SSR
All window/document access is inside useEffect, so the SDK is SSR-safe. On the server, status is "loading",
user is null, and error is null — gates render their loading slots, so render appropriate placeholders.
How the popup flow works
- User clicks Sign in →
signIn()callsGET /_oxa_auth/login→ returns a Keycloak authorize URL. - SDK opens that URL in a centered popup window (450 × 610).
- User signs in at Keycloak; Keycloak redirects the popup to
/_oxa_auth/callback. - The callback page exchanges the auth code for tokens (server-side), sets HTTP-only cookies on this host, and
postMessagesOXA_AUTH_SUCCESS(orOXA_AUTH_FAILURE) back to the opener. - The SDK receives the message, re-runs
test-app-token+get-me, andstatusflips toauthenticated.
If the popup is blocked, signIn() sets an error message instead of opening it. If the popup is closed mid-flow without
posting a message, the SDK polls for closure (every 500ms) and re-checks status as a fallback.
What the SDK still doesn't do
- The refresh token never touches JavaScript. Refresh rotation goes through
/_oxa_auth/refresh, which reads the HttpOnly refresh cookie server-side. - No client-side JWT decoding. All claim-derived data (email, name, roles, permissions) comes from
/_oxa_auth/get-me, surfaced viauseAuth().user.
License
ALL RIGHTS RESERVED
