auth-bundle-2 Phase 8: GUI auth surface (OIDC providers + group mappings + sessions + LoginPage IdP buttons + AuthState refactor + logout wiring)

Closes Phase 8 of cowork/auth-bundle-2-prompt.md. Every Bundle 2 endpoint
now has a permission-gated, data-testid-instrumented React surface.

Frontend changes
================

api/client.ts (Category H — AuthState refactor):
* fetchJSON now sends `credentials: 'include'` on every request so the
  HttpOnly session cookie + the JS-readable CSRF cookie ride along with
  Bearer-mode requests transparently. Mode is determined per call by
  what cookies are present, NOT by a state-machine — the same client
  works for Bearer-only deploys, session-only deploys, and the mixed
  upgrade path described in cowork/auth-bundles-index.md Category H.
* readCSRFCookie() + isStateChangingMethod() helpers auto-attach
  `X-CSRF-Token` to POST/PUT/PATCH/DELETE when the CSRF cookie exists.
  Bearer-only callers ride through unchanged (no CSRF cookie → no
  header → backend's CSRF middleware skips).
* AuthInfoResponse extended with optional `oidc_providers?:
  AuthInfoOIDCProvider[]` matching the Phase 6 server extension.
* New API helpers (1:1 with Phase 5 / 7.5 endpoints):
  - listOIDCProviders / createOIDCProvider / updateOIDCProvider /
    deleteOIDCProvider / refreshOIDCProvider
  - listGroupMappings / addGroupMapping / removeGroupMapping
  - listSessions(actorID?, actorType?) / revokeSession / logout
  - breakglassLogin / breakglassSetPassword / breakglassUnlock /
    breakglassRemove
  Permission gates fire server-side; the GUI predicates are UX only.

pages/auth/OIDCProvidersPage.tsx (NEW):
* Lists configured OIDC providers, gated on `auth.oidc.list`.
* Empty state + error state + loading state.
* Embedded Configure-Provider modal with form fields for name,
  issuer_url, client_id, client_secret, redirect_uri,
  groups_claim_path/format, fetch_userinfo, scopes. Modal hidden
  unless caller has `auth.oidc.create`.
* Unsaved-changes confirmation on cancel.

pages/auth/OIDCProviderDetailPage.tsx (NEW):
* Provider config dl + edit/delete/refresh action buttons.
* Edit and refresh require `auth.oidc.edit`. Delete requires
  `auth.oidc.delete`.
* Type-confirm-name delete dialog. Surfaces server's 409 Conflict
  ("ErrOIDCProviderInUse") inline so the operator knows to revoke
  the provider's active sessions first.
* Refresh discovery cache button → POST .../refresh → server re-runs
  RefreshKeys with the IdP-downgrade-attack defense from Phase 3.
* Group→role mappings link.

pages/auth/GroupMappingsPage.tsx (NEW):
* Per-provider group-claim → role-id mapping CRUD.
* Empty state explains the fail-closed semantics from Phase 3
  (no mappings ⇒ no users authenticate via this provider).
* Inline add form (group_name input + role_id select populated from
  `authListRoles`); add/remove gated on `auth.oidc.edit`.

pages/auth/SessionsPage.tsx (NEW):
* Default "My sessions" view available to anyone holding
  `auth.session.list`.
* "All actors (admin)" toggle exposed only when caller holds
  `auth.session.list.all`; renders an actor_id filter input that
  threads ?actor_id= through the GET.
* Self-pill marker on the caller's own rows.
* Revoke button is shown when (a) the row is the caller's own session
  (handler-side own-bypass) OR (b) caller holds `auth.session.revoke`.
* Confirms via window.confirm; surfaces revocation errors inline.

pages/LoginPage.tsx (MODIFIED):
* Fetches /v1/auth/info on mount; if `oidc_providers[]` is non-empty,
  renders one "Sign in with X" button per provider linking to the
  provider's `login_url` (the server-side handler in Phase 5 builds
  this URL with state + nonce + PKCE verifier sealed in the pre-login
  cookie; the GUI never touches those values).
* The API-key form remains as a fallback for Bearer-mode deploys and
  the Phase 7.5 break-glass path.
* All interactive elements carry data-testid:
  login-oidc-providers / login-oidc-button-{id} / login-api-key-form /
  login-api-key-input / login-api-key-submit.

components/AuthProvider.tsx (MODIFIED):
* logout() now also fires POST /auth/logout via the api/client helper
  before clearing local state. The endpoint is auth-exempt; the
  catch-and-swallow keeps the local logout flow working even if the
  cookie is already invalid (idempotent server-side as well).

components/Layout.tsx (MODIFIED):
* Two new nav entries under the Auth section: "OIDC Providers" + "Sessions".

main.tsx (MODIFIED):
* Four new routes:
  - /auth/oidc/providers
  - /auth/oidc/providers/:id
  - /auth/oidc/providers/:id/mappings
  - /auth/sessions

Vitest coverage
===============

Five new test files, 28 new test cases. Pattern matches Bundle 1
Phase 10's Vitest scaffold (vi.mock api/client, render with
QueryClient + MemoryRouter, authMe-driven permission shaping,
data-testid selectors).

* OIDCProvidersPage.test.tsx (5 tests): ErrorState w/o auth.oidc.list,
  empty state, list + create button render, hide-create-button
  without auth.oidc.create, submit-creates-via-API.
* OIDCProviderDetailPage.test.tsx (5 tests): ErrorState w/o list,
  full-perms render, hide edit/refresh/delete with only list,
  refresh button calls API, delete confirm-button stays disabled
  until typed text matches provider name.
* GroupMappingsPage.test.tsx (5 tests): ErrorState w/o list, empty
  fail-closed warning, mapping rows render, hide-form without
  auth.oidc.edit, submit-add-form-calls-API.
* SessionsPage.test.tsx (6 tests): ErrorState w/o list, own sessions
  + self-pill, hide All-actors toggle without list.all, show
  toggle with list.all, hide revoke on other-actor sessions without
  auth.session.revoke, click-revoke calls API after window.confirm.
* LoginPage.test.tsx (extended +2 tests): renders OIDC buttons when
  /auth/info reports providers; omits the OIDC block when none.

Verification
============

* `npx tsc --noEmit` — 0 errors.
* Vitest run across api/components/hooks/utils/auth/pages = 475 tests,
  all green.
* `npm run build` — green (980 KB bundle, no surprises vs Phase 7).
* No backend (Go) changes in this commit; Phase 5-7.5 surfaces
  consumed unchanged.

Not in this commit (deferred)
=============================

* "Test login flow" button on the provider detail page (prompt §Phase 8
  optional row). Requires a server-side test=true flag on the OIDC
  login handler — out of scope for the GUI commit.
* `web/src/__tests__/e2e/` Keycloak-via-testcontainers harness for the
  15 comprehensive flow checks. Tracked under Phase 10 of
  cowork/auth-bundle-2-prompt.md.
This commit is contained in:
shankar0123
2026-05-10 07:23:41 +00:00
parent 1d01c87663
commit 9143003e95
14 changed files with 2170 additions and 7 deletions
+232 -2
View File
@@ -55,10 +55,55 @@ function authHeaders(): Record<string, string> {
return headers;
}
// Bundle 2 Phase 8 — read the certctl_csrf cookie value (set by the
// OIDC-callback / break-glass-login flows; JS-readable by design so
// the GUI can echo it into the X-CSRF-Token header on every state-
// changing request). Returns empty string when the cookie isn't set
// (Bearer-mode deployments don't need CSRF; the server's middleware
// short-circuits CSRF for Bearer-authenticated requests).
function readCSRFCookie(): string {
if (typeof document === 'undefined' || !document.cookie) return '';
for (const part of document.cookie.split(';')) {
const [k, ...rest] = part.trim().split('=');
if (k === 'certctl_csrf') {
return decodeURIComponent(rest.join('='));
}
}
return '';
}
// isStateChangingMethod mirrors the server-side
// internal/auth/session/middleware.go::isStateChangingMethod predicate.
// State-changing requests get the X-CSRF-Token header auto-attached
// when in session-cookie mode; safe methods don't need it.
function isStateChangingMethod(method?: string): boolean {
switch ((method || 'GET').toUpperCase()) {
case 'POST':
case 'PUT':
case 'DELETE':
case 'PATCH':
return true;
default:
return false;
}
}
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
// Bundle 2 Phase 8 — credentials:'include' lets the certctl_session
// cookie ride along on every request. Bearer-mode deployments work
// unchanged (the cookie just isn't there). Auto-attach X-CSRF-Token
// header on state-changing methods when the cookie is present.
const headers: Record<string, string> = { ...authHeaders(), ...(init?.headers as Record<string, string> | undefined) };
if (isStateChangingMethod(init?.method)) {
const csrf = readCSRFCookie();
if (csrf && !headers['X-CSRF-Token']) {
headers['X-CSRF-Token'] = csrf;
}
}
const res = await fetch(url, {
headers: { ...authHeaders(), ...init?.headers },
...init,
credentials: 'include',
headers, // intentional: spread init first, then override headers with the merged map (init.headers already merged into `headers` above)
});
if (res.status === 401) {
// Trigger re-auth
@@ -81,9 +126,27 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
}
// Auth
//
// Bundle 2 Phase 6 / Category E — /auth/info now optionally returns
// the list of configured OIDC providers (id + display_name + login_url)
// when the server has any configured. The Login page renders the
// "Sign in with X" buttons from this list; older servers (pre-Phase-6)
// just return {auth_type, required} and the GUI falls back to the
// API-key form. Both shapes are valid; oidc_providers is an
// optional field on the wire.
export interface AuthInfoOIDCProvider {
id: string;
display_name: string;
login_url: string;
}
export interface AuthInfoResponse {
auth_type: string;
required: boolean;
oidc_providers?: AuthInfoOIDCProvider[];
}
export const getAuthInfo = () =>
fetch(`${BASE}/auth/info`, { headers: { 'Content-Type': 'application/json' } })
.then(r => r.json() as Promise<{ auth_type: string; required: boolean }>);
.then(r => r.json() as Promise<AuthInfoResponse>);
// AuthCheckResponse mirrors the /auth/check handler payload. Post-M-003 it
// surfaces `user` (named-key identity) and `admin` (named-key admin flag) so
@@ -223,6 +286,173 @@ export const authBootstrapAvailable = () =>
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json() as Promise<BootstrapAvailability>);
// =============================================================================
// Bundle 2 Phase 8 — OIDC providers + group mappings + sessions +
// break-glass admin API surface. Backs:
// - LoginPage (OIDC provider buttons + breakglass form)
// - OIDCProvidersPage + OIDCProviderDetailPage
// - GroupMappingsPage
// - SessionsPage (own + admin)
// - ProfilePage session-list panel
//
// Every function maps 1:1 to a Phase 5 / Phase 7.5 server endpoint;
// permission gates fire server-side, the GUI's permission-aware
// renders are a UX layer on top.
// =============================================================================
export interface OIDCProvider {
id: string;
tenant_id: string;
name: string;
issuer_url: string;
client_id: string;
redirect_uri: string;
groups_claim_path: string;
groups_claim_format: string;
fetch_userinfo: boolean;
scopes: string[];
allowed_email_domains?: string[];
iat_window_seconds: number;
jwks_cache_ttl_seconds: number;
created_at: string;
updated_at: string;
}
export interface OIDCProviderRequest {
name: string;
issuer_url: string;
client_id: string;
client_secret?: string; // sent on create + rotate; omitted on edit-without-rotate
redirect_uri: string;
groups_claim_path?: string;
groups_claim_format?: string;
fetch_userinfo?: boolean;
scopes?: string[];
allowed_email_domains?: string[];
iat_window_seconds?: number;
jwks_cache_ttl_seconds?: number;
}
export interface GroupRoleMapping {
id: string;
provider_id: string;
group_name: string;
role_id: string;
tenant_id: string;
created_at: string;
}
export interface SessionInfo {
id: string;
actor_id: string;
actor_type: string;
ip_address?: string;
user_agent?: string;
created_at: string;
last_seen_at: string;
idle_expires_at: string;
absolute_expires_at: string;
revoked: boolean;
}
// OIDC provider CRUD (auth.oidc.list / .create / .edit / .delete).
export const listOIDCProviders = () =>
fetchJSON<{ providers: OIDCProvider[] }>(`${BASE}/auth/oidc/providers`);
export const createOIDCProvider = (req: OIDCProviderRequest) =>
fetchJSON<OIDCProvider>(`${BASE}/auth/oidc/providers`, {
method: 'POST',
body: JSON.stringify(req),
});
export const updateOIDCProvider = (id: string, req: OIDCProviderRequest) =>
fetchJSON<OIDCProvider>(`${BASE}/auth/oidc/providers/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(req),
});
export const deleteOIDCProvider = (id: string) =>
fetchJSON<void>(`${BASE}/auth/oidc/providers/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
export const refreshOIDCProvider = (id: string) =>
fetchJSON<{ refreshed: boolean }>(`${BASE}/auth/oidc/providers/${encodeURIComponent(id)}/refresh`, {
method: 'POST',
});
// Group→role mapping CRUD (auth.oidc.list / .edit).
export const listGroupMappings = (providerID: string) =>
fetchJSON<{ mappings: GroupRoleMapping[] }>(
`${BASE}/auth/oidc/group-mappings?provider_id=${encodeURIComponent(providerID)}`,
);
export const addGroupMapping = (providerID: string, groupName: string, roleID: string) =>
fetchJSON<GroupRoleMapping>(`${BASE}/auth/oidc/group-mappings`, {
method: 'POST',
body: JSON.stringify({ provider_id: providerID, group_name: groupName, role_id: roleID }),
});
export const removeGroupMapping = (id: string) =>
fetchJSON<void>(`${BASE}/auth/oidc/group-mappings/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
// Session list + revoke. The GET also accepts ?actor_id=<other>
// for the admin all-actors view (auth.session.list.all gated server-
// side; see internal/api/router::router.go).
export const listSessions = (actorID?: string, actorType?: string) => {
const q = actorID ? `?actor_id=${encodeURIComponent(actorID)}${actorType ? '&actor_type=' + encodeURIComponent(actorType) : ''}` : '';
return fetchJSON<{ sessions: SessionInfo[] }>(`${BASE}/auth/sessions${q}`);
};
export const revokeSession = (sessionID: string) =>
fetchJSON<void>(`${BASE}/auth/sessions/${encodeURIComponent(sessionID)}`, {
method: 'DELETE',
});
// Logout — POST /auth/logout. Auth-exempt (the handler accepts the
// caller's session cookie OR a missing cookie; both 204).
export const logout = () =>
fetch(`/auth/logout`, { method: 'POST', credentials: 'include' }).then(r => {
if (!r.ok && r.status !== 204) throw new Error(`logout failed: ${r.status}`);
});
// =============================================================================
// Bundle 2 Phase 7.5 — break-glass admin surface. The login endpoint
// is auth-exempt; the admin endpoints require auth.breakglass.admin.
// All four endpoints return 404 when CERTCTL_BREAKGLASS_ENABLED=false
// (surface invisibility).
// =============================================================================
export const breakglassLogin = (actorID: string, password: string) =>
fetch(`/auth/breakglass/login`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actor_id: actorID, password }),
}).then(async r => {
if (r.status === 204) return;
if (r.status === 404) throw new Error('break-glass admin not enabled on this server');
if (!r.ok) throw new Error('invalid credentials');
});
export const breakglassSetPassword = (targetActorID: string, password: string) =>
fetchJSON<{ actor_id: string; created_at: string }>(`${BASE}/auth/breakglass/credentials`, {
method: 'POST',
body: JSON.stringify({ actor_id: targetActorID, password }),
});
export const breakglassUnlock = (targetActorID: string) =>
fetchJSON<void>(`${BASE}/auth/breakglass/credentials/${encodeURIComponent(targetActorID)}/unlock`, {
method: 'POST',
});
export const breakglassRemove = (targetActorID: string) =>
fetchJSON<void>(`${BASE}/auth/breakglass/credentials/${encodeURIComponent(targetActorID)}`, {
method: 'DELETE',
});
// =============================================================================
// Bundle 1 Phase 10 — approvals queue.
//