Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/steady-react-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@devalexllc/interlock-auth": minor
---

Add the React session provider, contract-aware fetch and notification socket, catalog client, and Compose demo.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ jobs:
cd "$consumer"
npm init -y >/dev/null
npm pkg set type=module
npm install --silent ./*.tgz
npm install --silent ./*.tgz react
for sub in "" "/next" "/react"; do
echo "import @devalexllc/interlock-auth$sub"
node --input-type=module -e "await import('@devalexllc/interlock-auth$sub')"
Expand Down
118 changes: 117 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Next.js and React applications use. One package, one session model, released on
| ----------- | ---------------------------------------------------------------------------------------------- | ----------------- |
| `.` | Better Auth options for the Keycloak provider, session-scoped grants, and claim normalization | Since 0.1.0 (3.5) |
| `./next` | The Next.js session behavior: session reads, the session route handler, provider and monitor | Empty until 8.1 |
| `./react` | A session provider over `better-auth/react` and a fetch wrapper that applies the auth contract | Empty until 3.6 |
| `./react` | A session provider over `better-auth/react` and a fetch wrapper that applies the auth contract | Implemented (3.6) |

The root entry stays free of React and Next.js, so a server or a test process can import it
without either.
Expand Down Expand Up @@ -197,3 +197,119 @@ npm-side organization name matching GitHub's canonical casing exactly.
## License

Apache License 2.0 — see [LICENSE](LICENSE). Copyright 2026 Devalex LLC.

## The `./react` entry

Install React 18.3 or 19 alongside the package. React is an optional peer so hosts using
only the server entry do not install it. The browser entry imports no server authentication
code and can also be imported in plain Node.

```tsx
import {
InterlockProvider,
useInterlockSession,
useInterlockFetch,
useCatalogClient,
useNotificationSocket,
useSocketEvent,
} from "@devalexllc/interlock-auth/react";

function Session() {
const { phase, user, signIn, signOut, retry } = useInterlockSession();
if (phase.kind === "loading") return <p>Loading…</p>;
if (phase.kind === "unavailable") return <button onClick={retry}>Retry</button>;
if (phase.kind !== "signed_in") return <button onClick={() => signIn()}>Sign in</button>;
return <button onClick={signOut}>Sign out {user?.name}</button>;
}

// Clear application data synchronously here, before subscribers render signed out.
<InterlockProvider uri="https://interlock.example.com" onSessionEnded={() => clearUserCache()}>
<Session />
</InterlockProvider>;
```

Provider configuration is fixed at mount; remount to change it. An absolute `uri` selects
that origin; omitted, empty, and relative values select the page origin. The auth and socket
paths are always `/api/auth` and `/ws/irongate`.

| Prop | Default | Purpose |
| ------------------------------------------ | -------------------------------- | ------------------------------------------------------------------------------- |
| `uri` | Page origin | Interlock origin |
| `callbackURL` | Page URL at sign-in time | Return URL for explicit and automatic login; a function is read at each sign-in |
| `onSessionEnded` | None | Synchronously clear user caches before publishing an ended state |
| `refetchIntervalSeconds` | `60` | Better Auth session polling; `0` disables polling |
| `unavailableRetryMs` | `5000` | Retry first-load session failures; `0` disables the timer |
| `socket.enabled` | `true` | Connect notifications while signed in |
| `socket.backoff` | `{ baseMs: 1000, capMs: 30000 }` | Full-jitter reconnect delay |
| `fetch`, `WebSocket`, `navigate`, `timers` | Browser implementations | Injectable ports for hosts and tests |

`useInterlockSession()` returns `{ phase, generation, reLoginUsedFor, dependency, user,
isSignedIn, signIn, signOut, retry }`. `phase.kind` is `loading`, `unavailable`, `signed_in`,
`signing_out`, `re_entering_login`, or `signed_out`. Better Auth reads the session on mount,
on refetch, when the page becomes visible, and periodically. A get-session failure preserves
an existing session; a failure before any session is known shows `unavailable`.

`useInterlockFetch()` returns `(path, { json?, query?, ...RequestInit }) => Promise<Response>`.
It always sends `credentials: "include"` to the configured origin. It never adds
`x-application-id`; Interlock derives application identity from the browser's Origin.

- Every 401 ends the session atomically and throws `SessionEndedError`. A plain 401 shows
login without navigating. A current-generation `refresh_indeterminate` while signed in
enters login once for that session ID. `onSessionEnded({ sessionId, reason, generation,
willReEnterLogin })` runs before subscribers see the new state.
- A 401 sent before logout cannot trigger automatic login after logout. Repeated 401s and
Better Auth's stale copy of the ended session cannot restore it. A different session ID
can sign in again. Provider-discovered session loss never redirects.
- A contract 503 `dependency_failure`, one of the three contract 502 codes, or a network
failure throws `DependencyFailureError` and sets `dependency` without ending the session.
The next successful BFF read clears that failure. Ordinary upstream errors pass through
as Responses; caller cancellation is rethrown without declaring a dependency failure.
- `signOut()` is global and never retries automatically. Its 200 or 503
`sign_out_incomplete` response leaves `signed_out` with `keycloakLogout` and
`signOutIncomplete`. Display `unconfirmed` as **“Signed out of this application, SSO may
still be active.”** A network failure retains the last session and refetches it.

`useNotificationSocket()` returns `{ phase, attempt, failure, send, subscribe, reconnectNow }`.
`subscribe` receives `{ event, data }`; `useSocketEvent<T>(event, handler)` subscribes to one
named event. `send({ event, data })` returns false unless open. There are no client pings.
The connector closes with 1000 as soon as the session leaves `signed_in`.

An established socket's 4401 uses the same session-ended transition. A 4503 preserves the
session, reports `database` or `upstream_unavailable`, and retries with backoff. Failed opens
and abnormal 1006 closes revalidate through **GET `/api/irongate/notifications/count`** using
the same fetch wrapper. Get-session does not run the grant refresher, so it cannot classify
these failures. Ordinary disconnects, including shutdown and backpressure, reconnect.

`useCatalogClient()` provides `listApplications()`, `groupsGranting(appId)`, `myGroups()`,
`listMyRequests(username)`, `createMemberRequest({ group, user, clientId })`,
`cancelRequest(id)`, `listNotifications(clientId)`, and `notificationCount()`. Every route
uses `/api/irongate`. A duplicate request returns `{ kind: "already_requested" }`; a created
request returns `{ kind: "created", request }`, with `request.id` normalized from `_id ?? id`.
Other non-contract upstream failures throw `UpstreamError` carrying the status and body.

The entry also exports the framework-free factories, reducers, error classes, and types:
`resolveEndpoints`, `classifyResponse`, `reduceSession`, `createSessionController`,
`createInterlockFetch`, `reduceSocket`, `createSocketConnector`, `createCatalogClient`, and
`backoffDelay`. These can be reused by another framework adapter without importing the
server entry.

### Compose demo

The unpublished `demo/` exercises username, catalog/groups, request/cancel, socket messages,
and the dependency and logout states. From this package checkout:

```sh
npm run demo
```

Open `https://demo.dev.home.arpa:5173`. The default Interlock origin is
`https://interlock.dev.home.arpa`; override it with `VITE_INTERLOCK_URI`. TLS defaults to
`../infralytics-stack/certs/dev.home.arpa.crt` and `.key`; override with `DEMO_TLS_CERT` and
`DEMO_TLS_KEY` when using a worktree elsewhere. No private key is copied into this package.

The Compose Interlock service must trust `https://demo.dev.home.arpa:5173`; this is
Compose-only development tooling, not a cluster-template setting. The browser needs the
local CA trust, hostname mapping, and Ann's client certificate. The host must run Better
Auth's migrator for `user.username` before serving this package version, and an existing user
must sign in again to populate it. Demo results are local Compose evidence only, never
cluster or enclave evidence.
223 changes: 223 additions & 0 deletions demo/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { useEffect, useState } from "react";

import {
InterlockProvider,
useCatalogClient,
useInterlockSession,
useNotificationSocket,
useSocketEvent,
type Application,
type KeycloakGroup,
type UserRequest,
} from "../src/react/index.js";

type CatalogRow = { application: Application; groups: KeycloakGroup[] };
function Dashboard() {
const session = useInterlockSession();
const catalog = useCatalogClient();
const socket = useNotificationSocket();
const [rows, setRows] = useState<CatalogRow[]>([]);
const [requests, setRequests] = useState<UserRequest[]>([]);
const [groups, setGroups] = useState<KeycloakGroup[]>([]);
const [message, setMessage] = useState("");
const [lastEvent, setLastEvent] = useState<unknown>(null);
const [revision, setRevision] = useState(0);
const username = session.user?.username;
const id = session.phase.kind === "signed_in" ? session.phase.session.session.id : null;
useSocketEvent("notifications", setLastEvent);
useEffect(() => {
if (!id) return undefined;
let active = true;
const load = async () => {
const [applications, ownGroups, ownRequests] = await Promise.all([
catalog.listApplications(),
catalog.myGroups(),
username ? catalog.listMyRequests(username) : Promise.resolve([]),
]);
const nextRows = await Promise.all(
applications.map(async (application) => ({
application,
groups: await catalog.groupsGranting(application.id),
})),
);
if (active) {
setRows(nextRows);
setGroups(ownGroups);
setRequests(ownRequests);
}
};
void load().catch((error: unknown) => {
if (active) setMessage(error instanceof Error ? error.message : "Request failed");
});
return () => {
active = false;
};
}, [id, username, catalog, revision]);
const act = (action: () => Promise<unknown>) => {
void action()
.then(() => setRevision((value) => value + 1))
.catch((error: unknown) =>
setMessage(error instanceof Error ? error.message : "Request failed"),
);
};
const signOut = session.phase.kind === "signed_out" ? session.phase : null;
return (
<main>
<header>
<p className="eyebrow">COMPOSE DEVELOPMENT ONLY</p>
<h1>Interlock client demo</h1>
<p>
Session: <strong>{session.phase.kind}</strong>
</p>
</header>
{session.dependency && (
<aside role="alert">
Dependency unavailable: {session.dependency.source} / {session.dependency.reason}.{" "}
<button
type="button"
onClick={() => {
void session.retry();
}}
>
Retry session
</button>
</aside>
)}
<nav>
<button
type="button"
onClick={() => {
void session.signIn();
}}
>
Sign in
</button>
<button
type="button"
disabled={session.phase.kind === "signing_out"}
onClick={() => {
void session.signOut();
}}
>
Sign out everywhere
</button>
<button
type="button"
onClick={() => {
void session.retry();
}}
>
Revalidate session
</button>
</nav>
{signOut && (
<section>
<h2>Signed out</h2>
<p>
{signOut.keycloakLogout === "unconfirmed"
? "Signed out of this application, SSO may still be active"
: signOut.keycloakLogout === "ended"
? "Your Keycloak session has ended."
: "Sign in to continue."}
</p>
{signOut.signOutIncomplete && (
<p role="alert">
Sign-out cleanup is incomplete. This request will not be retried automatically.
</p>
)}
</section>
)}
{session.isSignedIn && (
<>
<section>
<h2>{session.user?.name}</h2>
<p>Username: {username ?? "Not provided yet; sign in again after the migration"}</p>
<p>
Email:{" "}
{session.user?.email.endsWith("@keycloak.invalid")
? "Not provided (placeholder)"
: session.user?.email}
</p>
<p>My groups: {groups.map((group) => group.path).join(", ") || "None"}</p>
</section>
<section>
<h2>Applications</h2>
{rows.map(({ application, groups: granting }) => (
<article key={application.id}>
<h3>
<a href={application.url}>{application.name}</a>
</h3>
<p>{application.description}</p>
<p>{application.requestable ? "Requestable" : "Not requestable"}</p>
{granting.map((group) => (
<p key={group.id}>
{group.path}{" "}
<button
type="button"
disabled={!application.requestable || !username}
onClick={() =>
act(async () => {
if (!username) return;
const result = await catalog.createMemberRequest({
group: group.path,
user: username,
clientId: application.id,
});
setMessage(
result.kind === "already_requested"
? "Already requested"
: "Request created",
);
})
}
>
Request access
</button>
</p>
))}
</article>
))}
</section>
<section>
<h2>My requests</h2>
{requests.map((request) => (
<p key={request.id}>
{request.group} · {request.clientId}{" "}
<button
type="button"
onClick={() => act(async () => catalog.cancelRequest(request.id))}
>
Cancel
</button>
</p>
))}
</section>
</>
)}
{message && <p role="status">{message}</p>}
<section>
<h2>Notification socket</h2>
<p>
{socket.phase} · attempt {socket.attempt}
</p>
<p>Last failure: {socket.failure?.reason ?? "None"}</p>
<button type="button" onClick={socket.reconnectNow}>
Reconnect now
</button>
<pre>{JSON.stringify(lastEvent, null, 2)}</pre>
</section>
<footer>
This page validates the local Compose stack. It provides no cluster or enclave evidence.
</footer>
</main>
);
}
export function App() {
return (
<InterlockProvider
uri={import.meta.env.VITE_INTERLOCK_URI || "https://interlock.dev.home.arpa"}
>
<Dashboard />
</InterlockProvider>
);
}
1 change: 1 addition & 0 deletions demo/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="vite/client" />
12 changes: 12 additions & 0 deletions demo/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Interlock client demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
14 changes: 14 additions & 0 deletions demo/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";

import { App } from "./App.js";

import "./style.css";

const root = document.getElementById("root");
if (!root) throw new Error("Missing root element");
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
Loading
Loading