diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 15ff523..17ad029 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -55,10 +55,55 @@ function authHeaders(): Record { 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(url: string, init?: RequestInit): Promise { + // 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 = { ...authHeaders(), ...(init?.headers as Record | 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(url: string, init?: RequestInit): Promise { } // 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); // 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); +// ============================================================================= +// 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(`${BASE}/auth/oidc/providers`, { + method: 'POST', + body: JSON.stringify(req), + }); + +export const updateOIDCProvider = (id: string, req: OIDCProviderRequest) => + fetchJSON(`${BASE}/auth/oidc/providers/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify(req), + }); + +export const deleteOIDCProvider = (id: string) => + fetchJSON(`${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(`${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(`${BASE}/auth/oidc/group-mappings/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); + +// Session list + revoke. The GET also accepts ?actor_id= +// 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(`${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(`${BASE}/auth/breakglass/credentials/${encodeURIComponent(targetActorID)}/unlock`, { + method: 'POST', + }); + +export const breakglassRemove = (targetActorID: string) => + fetchJSON(`${BASE}/auth/breakglass/credentials/${encodeURIComponent(targetActorID)}`, { + method: 'DELETE', + }); + // ============================================================================= // Bundle 1 Phase 10 — approvals queue. // diff --git a/web/src/components/AuthProvider.tsx b/web/src/components/AuthProvider.tsx index 0bffbd1..9494f8b 100644 --- a/web/src/components/AuthProvider.tsx +++ b/web/src/components/AuthProvider.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, useState, useEffect, useCallback } from 'react'; import type { ReactNode } from 'react'; -import { getAuthInfo, checkAuth, setApiKey } from '../api/client'; +import { getAuthInfo, checkAuth, setApiKey, logout as apiLogout } from '../api/client'; interface AuthState { loading: boolean; @@ -96,6 +96,11 @@ export default function AuthProvider({ children }: { children: ReactNode }) { }, []); const logout = useCallback(() => { + // Bundle 2 Phase 8 — fire POST /auth/logout so the server can revoke the + // session row + clear the HttpOnly session cookie. The API logout helper + // sends `credentials: 'include'`. Errors are swallowed (the user's intent + // is still to be logged out locally; e.g. cookie already expired). + void apiLogout().catch(() => undefined); setApiKey(null); setAuthenticated(false); setUser(''); diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 352dcca..9cb80f4 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -27,6 +27,9 @@ const nav = [ { to: '/est', label: 'EST Admin', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' }, { to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' }, // Bundle 1 Phase 10 — RBAC management (Roles / Keys / Settings). + // Bundle 2 Phase 8 — OIDC + Sessions. + { to: '/auth/oidc/providers', label: 'OIDC Providers', icon: 'M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4' }, + { to: '/auth/sessions', label: 'Sessions', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' }, { to: '/auth/roles', label: 'Roles', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' }, { to: '/auth/keys', label: 'API Keys', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' }, { to: '/auth/approvals', label: 'Approvals', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' }, diff --git a/web/src/main.tsx b/web/src/main.tsx index 377eaa6..7879b01 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -41,6 +41,11 @@ import RoleDetailPage from './pages/auth/RoleDetailPage'; import KeysPage from './pages/auth/KeysPage'; import AuthSettingsPage from './pages/auth/AuthSettingsPage'; import ApprovalsPage from './pages/auth/ApprovalsPage'; +// Bundle 2 Phase 8 — OIDC + session management pages. +import OIDCProvidersPage from './pages/auth/OIDCProvidersPage'; +import OIDCProviderDetailPage from './pages/auth/OIDCProviderDetailPage'; +import GroupMappingsPage from './pages/auth/GroupMappingsPage'; +import SessionsPage from './pages/auth/SessionsPage'; import './index.css'; const queryClient = new QueryClient({ @@ -117,6 +122,11 @@ createRoot(document.getElementById('root')!).render( cached effective_permissions slice. Server-side enforcement is the load-bearing layer; client-side hide/disable is UX. */} + {/* Bundle 2 Phase 8 — OIDC + session management surface. */} + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/web/src/pages/LoginPage.test.tsx b/web/src/pages/LoginPage.test.tsx index ff842b6..b59fb8e 100644 --- a/web/src/pages/LoginPage.test.tsx +++ b/web/src/pages/LoginPage.test.tsx @@ -19,6 +19,11 @@ import type { ReactNode } from 'react'; // 1. The login form renders. // 2. An auth error containing a literal '; @@ -38,7 +43,12 @@ vi.mock('../components/AuthProvider', () => ({ }), })); +vi.mock('../api/client', () => ({ + getAuthInfo: vi.fn(), +})); + import LoginPage from './LoginPage'; +import * as client from '../api/client'; function renderWithRouter(ui: ReactNode) { return render({ui}); @@ -50,6 +60,11 @@ describe('LoginPage — render + XSS hardening (M-026 / M-029 Pass 3)', () => { cleanup(); mockError = null; delete (window as unknown as { __xss_pwned__?: number }).__xss_pwned__; + // Default: no providers configured. + vi.mocked(client.getAuthInfo).mockResolvedValue({ + auth_type: 'api-key', + required: true, + }); }); it('renders the login form', () => { @@ -92,4 +107,38 @@ describe('LoginPage — render + XSS hardening (M-026 / M-029 Pass 3)', () => { expect(screen.getByRole('button', { name: /Sign In/i })).toBeDisabled(); }); }); + + it('renders OIDC "Sign in with X" buttons when /auth/info returns providers (Bundle 2 Phase 8)', async () => { + vi.mocked(client.getAuthInfo).mockResolvedValue({ + auth_type: 'api-key', + required: true, + oidc_providers: [ + { id: 'op-okta', display_name: 'Okta', login_url: '/auth/oidc/login?provider_id=op-okta' }, + { id: 'op-google', display_name: 'Google', login_url: '/auth/oidc/login?provider_id=op-google' }, + ], + }); + renderWithRouter(); + await waitFor(() => { + expect(screen.getByTestId('login-oidc-providers')).toBeTruthy(); + }); + const oktaBtn = screen.getByTestId('login-oidc-button-op-okta') as HTMLAnchorElement; + expect(oktaBtn.href).toContain('/auth/oidc/login?provider_id=op-okta'); + expect(oktaBtn.textContent).toContain('Okta'); + const googleBtn = screen.getByTestId('login-oidc-button-op-google') as HTMLAnchorElement; + expect(googleBtn.textContent).toContain('Google'); + // API-key form remains as fallback. + expect(screen.getByTestId('login-api-key-form')).toBeTruthy(); + }); + + it('omits the OIDC block when /auth/info returns no providers (Bundle 2 Phase 8)', async () => { + vi.mocked(client.getAuthInfo).mockResolvedValue({ + auth_type: 'api-key', + required: true, + }); + renderWithRouter(); + await waitFor(() => { + expect(screen.getByTestId('login-api-key-form')).toBeTruthy(); + }); + expect(screen.queryByTestId('login-oidc-providers')).toBeNull(); + }); }); diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 4c62c2d..6079f86 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -1,14 +1,42 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useAuth } from '../components/AuthProvider'; +import { getAuthInfo, type AuthInfoOIDCProvider } from '../api/client'; + +// ============================================================================= +// LoginPage — Bundle 2 Phase 8 / multi-mode entry surface. +// +// Pre-Bundle-2: API-key-only sign-in form. +// Post-Bundle-2: when `/auth/info` reports `oidc_providers[]`, the +// page renders one "Sign in with X" button per provider; clicking +// navigates to the provider's `login_url` (which 302s through the +// IdP and back to /auth/oidc/callback). The API-key form remains as +// a fallback for Bearer-mode deployments + the break-glass path. +// ============================================================================= export default function LoginPage() { const { login, error: authError } = useAuth(); const [key, setKey] = useState(''); const [submitting, setSubmitting] = useState(false); const [localError, setLocalError] = useState(null); + const [providers, setProviders] = useState([]); const error = localError || authError; + // On mount, fetch /auth/info and extract any configured OIDC + // providers so we can render the "Sign in with X" buttons. Errors + // are non-fatal — fall back to the API-key form. + useEffect(() => { + getAuthInfo() + .then(info => { + if (info.oidc_providers && info.oidc_providers.length > 0) { + setProviders(info.oidc_providers); + } + }) + .catch(() => { + // Server may be pre-Phase-6; ignore. + }); + }, []); + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!key.trim()) return; @@ -31,7 +59,35 @@ export default function LoginPage() {

Certificate Control Plane

-
+ {providers.length > 0 && ( +
+

Sign in with your identity provider

+ {providers.map(p => ( + + Sign in with {p.display_name} + + ))} +
+ )} + + + {providers.length > 0 && ( +

+ — or sign in with API key — +

+ )}
{error && ( -
+
{error}
)} @@ -57,6 +117,7 @@ export default function LoginPage() { type="submit" disabled={submitting || !key.trim()} className="w-full bg-brand-400 hover:bg-brand-500 text-white py-2.5 text-sm font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + data-testid="login-api-key-submit" > {submitting ? 'Verifying...' : 'Sign In'} diff --git a/web/src/pages/auth/GroupMappingsPage.test.tsx b/web/src/pages/auth/GroupMappingsPage.test.tsx new file mode 100644 index 0000000..6f6651d --- /dev/null +++ b/web/src/pages/auth/GroupMappingsPage.test.tsx @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// Bundle 2 Phase 8 — GroupMappingsPage tests. Pins: +// - 403 ErrorState when caller lacks auth.oidc.list. +// - Empty mapping list renders the fail-closed-warning empty state. +// - Mapping list renders one row per mapping. +// - Add form HIDDEN without auth.oidc.edit. +// - Add form SHOWN with auth.oidc.edit + submission calls addGroupMapping. + +vi.mock('../../api/client', () => ({ + listGroupMappings: vi.fn(), + addGroupMapping: vi.fn(), + removeGroupMapping: vi.fn(), + authListRoles: vi.fn(), + authMe: vi.fn(), +})); + +import GroupMappingsPage from './GroupMappingsPage'; +import * as client from '../../api/client'; + +function renderRoute(ui: ReactNode, path = '/auth/oidc/providers/op-okta/mappings') { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + + + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + cleanup(); +}); + +const sampleRoles = [ + { id: 'r-admin', tenant_id: 't-default', name: 'admin', description: 'Full access' }, + { id: 'r-viewer', tenant_id: 't-default', name: 'viewer', description: 'Read-only' }, +]; + +const sampleMappings = [ + { + id: 'gm-1', + provider_id: 'op-okta', + group_name: 'engineers', + role_id: 'r-admin', + tenant_id: 't-default', + created_at: '2026-05-10T00:00:00Z', + }, +]; + +describe('GroupMappingsPage', () => { + it('renders ErrorState when caller lacks auth.oidc.list', async () => { + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-x', + actor_type: 'User', + tenant_id: 't-default', + admin: false, + roles: [], + effective_permissions: [], + }); + renderRoute(); + await waitFor(() => { + expect(screen.queryByText(/auth\.oidc\.list/)).toBeTruthy(); + }); + }); + + it('renders empty fail-closed warning when no mappings configured', async () => { + vi.mocked(client.listGroupMappings).mockResolvedValue({ mappings: [] }); + vi.mocked(client.authListRoles).mockResolvedValue(sampleRoles); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-admin', + actor_type: 'User', + tenant_id: 't-default', + admin: true, + roles: ['r-admin'], + effective_permissions: [{ permission: 'auth.oidc.list', scope_type: 'global' }], + }); + renderRoute(); + await waitFor(() => { + expect(screen.getByTestId('group-mappings-empty')).toBeTruthy(); + }); + }); + + it('renders mapping rows from listGroupMappings', async () => { + vi.mocked(client.listGroupMappings).mockResolvedValue({ mappings: sampleMappings }); + vi.mocked(client.authListRoles).mockResolvedValue(sampleRoles); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-admin', + actor_type: 'User', + tenant_id: 't-default', + admin: true, + roles: ['r-admin'], + effective_permissions: [ + { permission: 'auth.oidc.list', scope_type: 'global' }, + { permission: 'auth.oidc.edit', scope_type: 'global' }, + ], + }); + renderRoute(); + await waitFor(() => { + expect(screen.getByTestId('group-mapping-row-gm-1')).toBeTruthy(); + }); + expect(screen.getByText('engineers')).toBeTruthy(); + expect(screen.getByText('r-admin')).toBeTruthy(); + expect(screen.getByTestId('group-mapping-remove-gm-1')).toBeTruthy(); + }); + + it('hides the add form when caller lacks auth.oidc.edit', async () => { + vi.mocked(client.listGroupMappings).mockResolvedValue({ mappings: sampleMappings }); + vi.mocked(client.authListRoles).mockResolvedValue(sampleRoles); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-viewer', + actor_type: 'User', + tenant_id: 't-default', + admin: false, + roles: ['r-viewer'], + effective_permissions: [{ permission: 'auth.oidc.list', scope_type: 'global' }], + }); + renderRoute(); + await waitFor(() => { + expect(screen.getByTestId('group-mapping-row-gm-1')).toBeTruthy(); + }); + expect(screen.queryByTestId('group-mappings-add-form')).toBeNull(); + // Remove button is also hidden in row when caller lacks edit. + expect(screen.queryByTestId('group-mapping-remove-gm-1')).toBeNull(); + }); + + it('submitting the add form calls addGroupMapping', async () => { + vi.mocked(client.listGroupMappings).mockResolvedValue({ mappings: [] }); + vi.mocked(client.authListRoles).mockResolvedValue(sampleRoles); + vi.mocked(client.addGroupMapping).mockResolvedValue(sampleMappings[0]); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-admin', + actor_type: 'User', + tenant_id: 't-default', + admin: true, + roles: ['r-admin'], + effective_permissions: [ + { permission: 'auth.oidc.list', scope_type: 'global' }, + { permission: 'auth.oidc.edit', scope_type: 'global' }, + ], + }); + renderRoute(); + await waitFor(() => { + expect(screen.getByTestId('group-mappings-add-form')).toBeTruthy(); + }); + fireEvent.change(screen.getByTestId('group-mappings-group-name-input'), { + target: { value: 'engineers' }, + }); + fireEvent.change(screen.getByTestId('group-mappings-role-select'), { + target: { value: 'r-admin' }, + }); + fireEvent.click(screen.getByTestId('group-mappings-add-button')); + await waitFor(() => { + expect(client.addGroupMapping).toHaveBeenCalledWith('op-okta', 'engineers', 'r-admin'); + }); + }); +}); diff --git a/web/src/pages/auth/GroupMappingsPage.tsx b/web/src/pages/auth/GroupMappingsPage.tsx new file mode 100644 index 0000000..fad19fe --- /dev/null +++ b/web/src/pages/auth/GroupMappingsPage.tsx @@ -0,0 +1,227 @@ +import { useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + listGroupMappings, + addGroupMapping, + removeGroupMapping, + authListRoles, + type GroupRoleMapping, +} from '../../api/client'; +import { useAuthMe } from '../../hooks/useAuthMe'; +import PageHeader from '../../components/PageHeader'; +import ErrorState from '../../components/ErrorState'; + +// ============================================================================= +// Bundle 2 Phase 8 — GroupMappingsPage. +// +// Per-OIDC-provider group→role mappings. The OIDC service consults the +// list at HandleCallback time (Phase 3) to translate IdP-supplied +// group claims into role IDs that get attached to the post-login +// session. Empty mapping list ⇒ no users can authenticate via this +// provider (fail-closed); operators add at least one mapping before +// rolling out OIDC. +// +// Routes: +// /auth/oidc/providers/{id}/mappings — this page. +// API: +// GET /api/v1/auth/oidc/group-mappings?provider_id={id} +// POST /api/v1/auth/oidc/group-mappings +// DELETE /api/v1/auth/oidc/group-mappings/{id} +// Permissions: auth.oidc.list (page) + auth.oidc.edit (add/remove). +// ============================================================================= + +export default function GroupMappingsPage() { + const { id: providerID } = useParams<{ id: string }>(); + const queryClient = useQueryClient(); + const { hasPerm } = useAuthMe(); + + const canList = hasPerm('auth.oidc.list'); + const canEdit = hasPerm('auth.oidc.edit'); + + const [groupName, setGroupName] = useState(''); + const [roleID, setRoleID] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const { data, isLoading, error: loadErr } = useQuery({ + queryKey: ['group-mappings', providerID], + queryFn: () => listGroupMappings(providerID || ''), + enabled: canList && !!providerID, + }); + const { data: rolesData } = useQuery({ + queryKey: ['auth-roles'], + queryFn: authListRoles, + enabled: canEdit, + }); + + if (!canList) { + return ( +
+ + +
+ ); + } + + const handleAdd = async (e: React.FormEvent) => { + e.preventDefault(); + if (!groupName.trim() || !roleID || !providerID) return; + setSubmitting(true); + setError(null); + try { + await addGroupMapping(providerID, groupName.trim(), roleID); + setGroupName(''); + setRoleID(''); + queryClient.invalidateQueries({ queryKey: ['group-mappings', providerID] }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSubmitting(false); + } + }; + + const handleRemove = async (mappingID: string, displayName: string) => { + if (!window.confirm(`Remove the mapping for "${displayName}"?`)) return; + try { + await removeGroupMapping(mappingID); + queryClient.invalidateQueries({ queryKey: ['group-mappings', providerID] }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + return ( +
+ + ← Provider + + } + /> + + {error && ( +
+ {error} +
+ )} + + {canEdit && ( + +

Add mapping

+
+
+ + setGroupName(e.target.value)} + placeholder="engineers" + className="w-full px-2 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" + data-testid="group-mappings-group-name-input" + /> +
+
+ + +
+
+ +
+
+ + )} + + {isLoading && ( +
+ Loading mappings… +
+ )} + {loadErr && } + + {data && data.mappings.length === 0 && ( +
+

+ No mappings configured for this provider. Until at least one mapping exists, OIDC logins + via this provider fail closed (no roles → 401 to the user). +

+
+ )} + + {data && data.mappings.length > 0 && ( +
+ + + + + + + + + + + {data.mappings.map((m: GroupRoleMapping) => ( + + + + + + + ))} + +
IdP groupcertctl roleCreatedActions
{m.group_name}{m.role_id} + {m.created_at ? new Date(m.created_at).toLocaleDateString() : '—'} + + {canEdit && ( + + )} +
+
+ )} +
+ ); +} diff --git a/web/src/pages/auth/OIDCProviderDetailPage.test.tsx b/web/src/pages/auth/OIDCProviderDetailPage.test.tsx new file mode 100644 index 0000000..35444a6 --- /dev/null +++ b/web/src/pages/auth/OIDCProviderDetailPage.test.tsx @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// Bundle 2 Phase 8 — OIDCProviderDetailPage tests. Pins: +// - 403 ErrorState when caller lacks auth.oidc.list. +// - "Edit"/"Refresh"/"Delete" buttons HIDDEN without their respective perms. +// - "Edit"/"Refresh"/"Delete" buttons SHOWN when perms present. +// - Refresh button calls refreshOIDCProvider. +// - Delete confirmation flow + button enabled only when typed text matches. + +vi.mock('../../api/client', () => ({ + listOIDCProviders: vi.fn(), + updateOIDCProvider: vi.fn(), + deleteOIDCProvider: vi.fn(), + refreshOIDCProvider: vi.fn(), + authMe: vi.fn(), +})); + +import OIDCProviderDetailPage from './OIDCProviderDetailPage'; +import * as client from '../../api/client'; + +function renderRoute(ui: ReactNode, path = '/auth/oidc/providers/op-okta') { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + + + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + cleanup(); +}); + +const sampleProvider = { + id: 'op-okta', + tenant_id: 't-default', + name: 'Okta', + issuer_url: 'https://example.okta.com', + client_id: 'certctl', + redirect_uri: 'https://certctl.example.com/auth/oidc/callback', + groups_claim_path: 'groups', + groups_claim_format: 'string-array', + fetch_userinfo: false, + scopes: ['openid'], + iat_window_seconds: 300, + jwks_cache_ttl_seconds: 3600, + created_at: '2026-05-10T00:00:00Z', + updated_at: '2026-05-10T00:00:00Z', +}; + +describe('OIDCProviderDetailPage', () => { + it('renders ErrorState when caller lacks auth.oidc.list', async () => { + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-x', + actor_type: 'User', + tenant_id: 't-default', + admin: false, + roles: [], + effective_permissions: [], + }); + renderRoute(); + await waitFor(() => { + expect(screen.queryByText(/auth\.oidc\.list/)).toBeTruthy(); + }); + }); + + it('renders provider config and edit/delete/refresh buttons with full perms', async () => { + vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [sampleProvider] }); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-admin', + actor_type: 'User', + tenant_id: 't-default', + admin: true, + roles: ['r-admin'], + effective_permissions: [ + { permission: 'auth.oidc.list', scope_type: 'global' }, + { permission: 'auth.oidc.edit', scope_type: 'global' }, + { permission: 'auth.oidc.delete', scope_type: 'global' }, + ], + }); + renderRoute(); + await waitFor(() => { + expect(screen.getByTestId('oidc-provider-edit-button')).toBeTruthy(); + }); + expect(screen.getByTestId('oidc-provider-refresh-button')).toBeTruthy(); + expect(screen.getByTestId('oidc-provider-delete-button')).toBeTruthy(); + expect(screen.getByTestId('oidc-provider-mappings-link')).toBeTruthy(); + // The provider's issuer_url renders in the dl. + expect(screen.getAllByText('https://example.okta.com').length).toBeGreaterThan(0); + }); + + it('hides edit/refresh/delete when caller has only auth.oidc.list', async () => { + vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [sampleProvider] }); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-viewer', + actor_type: 'User', + tenant_id: 't-default', + admin: false, + roles: ['r-viewer'], + effective_permissions: [{ permission: 'auth.oidc.list', scope_type: 'global' }], + }); + renderRoute(); + await waitFor(() => { + expect(screen.getByTestId('oidc-provider-mappings-link')).toBeTruthy(); + }); + expect(screen.queryByTestId('oidc-provider-edit-button')).toBeNull(); + expect(screen.queryByTestId('oidc-provider-refresh-button')).toBeNull(); + expect(screen.queryByTestId('oidc-provider-delete-button')).toBeNull(); + }); + + it('refresh button calls refreshOIDCProvider', async () => { + vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [sampleProvider] }); + vi.mocked(client.refreshOIDCProvider).mockResolvedValue({ refreshed: true }); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-admin', + actor_type: 'User', + tenant_id: 't-default', + admin: true, + roles: ['r-admin'], + effective_permissions: [ + { permission: 'auth.oidc.list', scope_type: 'global' }, + { permission: 'auth.oidc.edit', scope_type: 'global' }, + ], + }); + renderRoute(); + await waitFor(() => { + expect(screen.getByTestId('oidc-provider-refresh-button')).toBeTruthy(); + }); + fireEvent.click(screen.getByTestId('oidc-provider-refresh-button')); + await waitFor(() => { + expect(client.refreshOIDCProvider).toHaveBeenCalledWith('op-okta'); + }); + }); + + it('delete confirm button stays disabled until typed text matches provider name', async () => { + vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [sampleProvider] }); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-admin', + actor_type: 'User', + tenant_id: 't-default', + admin: true, + roles: ['r-admin'], + effective_permissions: [ + { permission: 'auth.oidc.list', scope_type: 'global' }, + { permission: 'auth.oidc.delete', scope_type: 'global' }, + ], + }); + renderRoute(); + await waitFor(() => { + expect(screen.getByTestId('oidc-provider-delete-button')).toBeTruthy(); + }); + fireEvent.click(screen.getByTestId('oidc-provider-delete-button')); + await waitFor(() => { + expect(screen.getByTestId('oidc-provider-delete-confirm')).toBeTruthy(); + }); + const confirmBtn = screen.getByTestId('oidc-provider-delete-confirm-button') as HTMLButtonElement; + expect(confirmBtn.disabled).toBe(true); + fireEvent.change(screen.getByTestId('oidc-provider-delete-confirm-input'), { + target: { value: 'Wrong' }, + }); + expect(confirmBtn.disabled).toBe(true); + fireEvent.change(screen.getByTestId('oidc-provider-delete-confirm-input'), { + target: { value: 'Okta' }, + }); + expect(confirmBtn.disabled).toBe(false); + }); +}); diff --git a/web/src/pages/auth/OIDCProviderDetailPage.tsx b/web/src/pages/auth/OIDCProviderDetailPage.tsx new file mode 100644 index 0000000..cac577e --- /dev/null +++ b/web/src/pages/auth/OIDCProviderDetailPage.tsx @@ -0,0 +1,367 @@ +import { useState } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + listOIDCProviders, + updateOIDCProvider, + deleteOIDCProvider, + refreshOIDCProvider, + type OIDCProvider, +} from '../../api/client'; +import { useAuthMe } from '../../hooks/useAuthMe'; +import PageHeader from '../../components/PageHeader'; +import ErrorState from '../../components/ErrorState'; + +// ============================================================================= +// Bundle 2 Phase 8 — OIDCProviderDetailPage. +// +// One row per provider — edit (PUT), delete (DELETE), and refresh +// discovery cache (POST .../refresh). Edit modal shares the create- +// modal field set; the client_secret field is OPTIONAL on edit (empty +// preserves the existing ciphertext on the server). Delete is gated +// behind a typed-confirmation dialog AND surfaces 409 Conflict (the +// server's ErrOIDCProviderInUse) as a non-destructive error so the +// operator knows to revoke active sessions first. Refresh discovery +// cache fires the server's RefreshKeys → re-runs the IdP downgrade- +// attack defense AND re-fetches JWKS; common operator action when an +// IdP rotates keys mid-day. +// +// Permission gates: the page itself requires auth.oidc.list. Edit +// and refresh require auth.oidc.edit. Delete requires +// auth.oidc.delete. Mappings link is rendered for any caller with +// auth.oidc.list. +// ============================================================================= + +export default function OIDCProviderDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { hasPerm } = useAuthMe(); + + const canList = hasPerm('auth.oidc.list'); + const canEdit = hasPerm('auth.oidc.edit'); + const canDelete = hasPerm('auth.oidc.delete'); + + const [editing, setEditing] = useState(false); + const [editName, setEditName] = useState(''); + const [editIssuerURL, setEditIssuerURL] = useState(''); + const [editClientID, setEditClientID] = useState(''); + const [editClientSecret, setEditClientSecret] = useState(''); + const [editRedirectURI, setEditRedirectURI] = useState(''); + const [editFetchUserinfo, setEditFetchUserinfo] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [deleteConfirmText, setDeleteConfirmText] = useState(''); + + const { data, isLoading, error: loadErr } = useQuery({ + queryKey: ['oidc-providers'], + queryFn: listOIDCProviders, + enabled: canList, + }); + + if (!canList) { + return ( +
+ + +
+ ); + } + + const provider: OIDCProvider | undefined = data?.providers.find(p => p.id === id); + + if (isLoading) { + return
Loading…
; + } + if (loadErr || !provider) { + return ( +
+ + + + ← Back to providers + +
+ ); + } + + const startEdit = () => { + setEditName(provider.name); + setEditIssuerURL(provider.issuer_url); + setEditClientID(provider.client_id); + setEditClientSecret(''); + setEditRedirectURI(provider.redirect_uri); + setEditFetchUserinfo(provider.fetch_userinfo || false); + setError(null); + setSuccess(null); + setEditing(true); + }; + + const cancelEdit = () => { + setEditing(false); + setError(null); + }; + + const saveEdit = async () => { + setSubmitting(true); + setError(null); + setSuccess(null); + try { + const req: Parameters[1] = { + name: editName, + issuer_url: editIssuerURL, + client_id: editClientID, + redirect_uri: editRedirectURI, + groups_claim_path: provider.groups_claim_path, + groups_claim_format: provider.groups_claim_format, + fetch_userinfo: editFetchUserinfo, + scopes: provider.scopes, + iat_window_seconds: provider.iat_window_seconds, + jwks_cache_ttl_seconds: provider.jwks_cache_ttl_seconds, + }; + if (editClientSecret) req.client_secret = editClientSecret; + await updateOIDCProvider(provider.id, req); + setSuccess('Provider updated'); + setEditing(false); + queryClient.invalidateQueries({ queryKey: ['oidc-providers'] }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSubmitting(false); + } + }; + + const doRefresh = async () => { + setSubmitting(true); + setError(null); + setSuccess(null); + try { + await refreshOIDCProvider(provider.id); + setSuccess('Discovery + JWKS refreshed; IdP downgrade defense re-run'); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSubmitting(false); + } + }; + + const doDelete = async () => { + setSubmitting(true); + setError(null); + try { + await deleteOIDCProvider(provider.id); + navigate('/auth/oidc/providers'); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setSubmitting(false); + } + }; + + return ( +
+ + ← All providers + + } + /> + + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + +
+

Configuration

+ {!editing ? ( +
+
Issuer URL
+
{provider.issuer_url}
+
Client ID
+
{provider.client_id}
+
Redirect URI
+
{provider.redirect_uri}
+
Groups claim
+
+ {provider.groups_claim_path} ({provider.groups_claim_format}) +
+
Userinfo fallback
+
{provider.fetch_userinfo ? 'enabled' : 'disabled'}
+
Scopes
+
{(provider.scopes || []).join(', ')}
+
IAT window
+
{provider.iat_window_seconds}s
+
+ ) : ( +
+
+ + setEditName(e.target.value)} + className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" + data-testid="oidc-provider-edit-name" + /> +
+
+ + setEditIssuerURL(e.target.value)} + className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" + data-testid="oidc-provider-edit-issuer-url" + /> +
+
+ + setEditClientID(e.target.value)} + className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" + data-testid="oidc-provider-edit-client-id" + /> +
+
+ + setEditClientSecret(e.target.value)} + className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" + data-testid="oidc-provider-edit-client-secret" + /> +
+
+ + setEditRedirectURI(e.target.value)} + className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" + data-testid="oidc-provider-edit-redirect-uri" + /> +
+ +
+ )} +
+ +
+

Actions

+
+ {canEdit && !editing && ( + + )} + {editing && ( + <> + + + + )} + {canEdit && ( + + )} + + Group → role mappings + + {canDelete && !confirmDelete && ( + + )} +
+ + {confirmDelete && ( +
+

+ Type {provider.name} to confirm deletion. + Deletion is refused (HTTP 409) when any user has authenticated via this provider; revoke + their sessions first. +

+
+ setDeleteConfirmText(e.target.value)} + className="flex-1 px-2 py-1 text-sm border border-red-300 rounded bg-white" + data-testid="oidc-provider-delete-confirm-input" + /> + + +
+
+ )} +
+
+ ); +} diff --git a/web/src/pages/auth/OIDCProvidersPage.test.tsx b/web/src/pages/auth/OIDCProvidersPage.test.tsx new file mode 100644 index 0000000..142607b --- /dev/null +++ b/web/src/pages/auth/OIDCProvidersPage.test.tsx @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// Bundle 2 Phase 8 — OIDCProvidersPage tests. Pins: +// - Page 403's (renders ErrorState) when caller lacks auth.oidc.list. +// - Empty state renders when no providers. +// - List renders + name links to detail page. +// - "Configure provider" button HIDDEN without auth.oidc.create. +// - "Configure provider" button SHOWN with auth.oidc.create + submit +// calls createOIDCProvider. + +vi.mock('../../api/client', () => ({ + listOIDCProviders: vi.fn(), + createOIDCProvider: vi.fn(), + authMe: vi.fn(), +})); + +import OIDCProvidersPage from './OIDCProvidersPage'; +import * as client from '../../api/client'; + +function renderWithProviders(ui: ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + {ui} + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + cleanup(); +}); + +const sample = [ + { + id: 'op-okta', + tenant_id: 't-default', + name: 'Okta', + issuer_url: 'https://example.okta.com', + client_id: 'certctl', + redirect_uri: 'https://certctl.example.com/auth/oidc/callback', + groups_claim_path: 'groups', + groups_claim_format: 'string-array', + fetch_userinfo: false, + scopes: ['openid'], + iat_window_seconds: 300, + jwks_cache_ttl_seconds: 3600, + created_at: '2026-05-10T00:00:00Z', + updated_at: '2026-05-10T00:00:00Z', + }, +]; + +describe('OIDCProvidersPage', () => { + it('renders ErrorState when caller lacks auth.oidc.list', async () => { + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-x', + actor_type: 'User', + tenant_id: 't-default', + admin: false, + roles: [], + effective_permissions: [], + }); + renderWithProviders(); + await waitFor(() => { + expect(screen.queryByText(/auth\.oidc\.list/)).toBeTruthy(); + }); + }); + + it('renders empty state when no providers configured', async () => { + vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [] }); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-x', + actor_type: 'User', + tenant_id: 't-default', + admin: true, + roles: ['r-admin'], + effective_permissions: [{ permission: 'auth.oidc.list', scope_type: 'global' }], + }); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('oidc-providers-empty')).toBeTruthy(); + }); + }); + + it('renders list + create button when caller has auth.oidc.create', async () => { + vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: sample }); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-admin', + actor_type: 'User', + tenant_id: 't-default', + admin: true, + roles: ['r-admin'], + effective_permissions: [ + { permission: 'auth.oidc.list', scope_type: 'global' }, + { permission: 'auth.oidc.create', scope_type: 'global' }, + ], + }); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('oidc-provider-row-op-okta')).toBeTruthy(); + }); + expect(screen.getByTestId('oidc-providers-create-button')).toBeTruthy(); + expect(screen.getByText('Okta')).toBeTruthy(); + }); + + it('hides create button without auth.oidc.create', async () => { + vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: sample }); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-viewer', + actor_type: 'User', + tenant_id: 't-default', + admin: false, + roles: ['r-viewer'], + effective_permissions: [{ permission: 'auth.oidc.list', scope_type: 'global' }], + }); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('oidc-provider-row-op-okta')).toBeTruthy(); + }); + expect(screen.queryByTestId('oidc-providers-create-button')).toBeNull(); + }); + + it('submits the create modal via createOIDCProvider', async () => { + vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [] }); + vi.mocked(client.createOIDCProvider).mockResolvedValue(sample[0]); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-admin', + actor_type: 'User', + tenant_id: 't-default', + admin: true, + roles: ['r-admin'], + effective_permissions: [ + { permission: 'auth.oidc.list', scope_type: 'global' }, + { permission: 'auth.oidc.create', scope_type: 'global' }, + ], + }); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('oidc-providers-create-button')).toBeTruthy(); + }); + fireEvent.click(screen.getByTestId('oidc-providers-create-button')); + await waitFor(() => { + expect(screen.getByTestId('create-oidc-provider-modal')).toBeTruthy(); + }); + fireEvent.change(screen.getByTestId('oidc-provider-name-input'), { target: { value: 'Okta' } }); + fireEvent.change(screen.getByTestId('oidc-provider-issuer-url-input'), { + target: { value: 'https://example.okta.com' }, + }); + fireEvent.change(screen.getByTestId('oidc-provider-client-id-input'), { target: { value: 'certctl' } }); + fireEvent.change(screen.getByTestId('oidc-provider-client-secret-input'), { + target: { value: 'super-secret' }, + }); + fireEvent.change(screen.getByTestId('oidc-provider-redirect-uri-input'), { + target: { value: 'https://certctl.example.com/auth/oidc/callback' }, + }); + fireEvent.click(screen.getByTestId('create-oidc-provider-submit')); + await waitFor(() => { + expect(client.createOIDCProvider).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/web/src/pages/auth/OIDCProvidersPage.tsx b/web/src/pages/auth/OIDCProvidersPage.tsx new file mode 100644 index 0000000..380a2b6 --- /dev/null +++ b/web/src/pages/auth/OIDCProvidersPage.tsx @@ -0,0 +1,318 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + listOIDCProviders, + createOIDCProvider, + type OIDCProvider, + type OIDCProviderRequest, +} from '../../api/client'; +import { useAuthMe } from '../../hooks/useAuthMe'; +import PageHeader from '../../components/PageHeader'; +import ErrorState from '../../components/ErrorState'; + +// ============================================================================= +// Bundle 2 Phase 8 — OIDCProvidersPage. +// +// Lists every configured OIDC identity provider in the tenant. Each +// row shows id, name, issuer URL, client_id, and a deep-link to the +// provider detail page. +// +// Render-time permission gating: +// - Page itself requires auth.oidc.list; non-holders see an +// ErrorState directing them to ask an admin. +// - "Configure provider" button is HIDDEN unless the caller holds +// auth.oidc.create (server-side enforcement is still load-bearing). +// +// data-testid attributes flag every interactive element so the future +// E2E suite can assert behaviour without brittle CSS selectors. Same +// pattern as Bundle 1's RolesPage. +// ============================================================================= + +interface CreateProviderModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +function CreateProviderModal({ isOpen, onClose, onSuccess }: CreateProviderModalProps) { + const [form, setForm] = useState({ + name: '', + issuer_url: '', + client_id: '', + client_secret: '', + redirect_uri: '', + groups_claim_path: 'groups', + groups_claim_format: 'string-array', + fetch_userinfo: false, + scopes: ['openid', 'profile', 'email'], + iat_window_seconds: 300, + jwks_cache_ttl_seconds: 3600, + }); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [dirty, setDirty] = useState(false); + + if (!isOpen) return null; + + const update = (k: K, v: OIDCProviderRequest[K]) => { + setForm(prev => ({ ...prev, [k]: v })); + setDirty(true); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!form.name.trim() || !form.issuer_url.trim() || !form.client_id.trim() || !form.client_secret) return; + setSubmitting(true); + setError(null); + try { + await createOIDCProvider(form); + setDirty(false); + onSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSubmitting(false); + } + }; + + const handleClose = () => { + if (dirty && !window.confirm('Discard unsaved changes?')) return; + setDirty(false); + setError(null); + onClose(); + }; + + return ( +
+
e.stopPropagation()} + data-testid="create-oidc-provider-modal" + > +

Configure OIDC provider

+ {error && ( +
+ {error} +
+ )} +
+
+ + update('name', e.target.value)} + className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" + required + data-testid="oidc-provider-name-input" + /> +
+
+ + update('issuer_url', e.target.value)} + placeholder="https://idp.example.com/realm/main" + className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" + required + data-testid="oidc-provider-issuer-url-input" + /> +
+
+
+ + update('client_id', e.target.value)} + className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" + required + data-testid="oidc-provider-client-id-input" + /> +
+
+ + update('client_secret', e.target.value)} + className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" + required + data-testid="oidc-provider-client-secret-input" + /> +
+
+
+ + update('redirect_uri', e.target.value)} + placeholder="https://certctl.example.com/auth/oidc/callback" + className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" + required + data-testid="oidc-provider-redirect-uri-input" + /> +
+
+
+ + update('groups_claim_path', e.target.value)} + className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" + data-testid="oidc-provider-groups-claim-path-input" + /> +
+
+ + +
+
+ +
+ + +
+
+
+
+ ); +} + +export default function OIDCProvidersPage() { + const { hasPerm } = useAuthMe(); + const queryClient = useQueryClient(); + const [showCreate, setShowCreate] = useState(false); + + const canList = hasPerm('auth.oidc.list'); + const canCreate = hasPerm('auth.oidc.create'); + + const { data, isLoading, error } = useQuery({ + queryKey: ['oidc-providers'], + queryFn: listOIDCProviders, + enabled: canList, + }); + + if (!canList) { + return ( +
+ + +
+ ); + } + + return ( +
+ setShowCreate(true)} + className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700" + data-testid="oidc-providers-create-button" + > + Configure provider + + ) + } + /> + + {isLoading && ( +
+ Loading providers… +
+ )} + {error && } + + {data && data.providers.length === 0 && ( +
+

+ No OIDC providers configured.{' '} + {canCreate ? 'Click "Configure provider" to add one.' : 'Ask an administrator to configure one.'} +

+
+ )} + + {data && data.providers.length > 0 && ( +
+ + + + + + + + + + + {data.providers.map((p: OIDCProvider) => ( + + + + + + + ))} + +
NameIssuer URLClient IDCreated
+ + {p.name} + + {p.issuer_url}{p.client_id} + {p.created_at ? new Date(p.created_at).toLocaleDateString() : '—'} +
+
+ )} + + setShowCreate(false)} + onSuccess={() => { + setShowCreate(false); + queryClient.invalidateQueries({ queryKey: ['oidc-providers'] }); + }} + /> +
+ ); +} diff --git a/web/src/pages/auth/SessionsPage.test.tsx b/web/src/pages/auth/SessionsPage.test.tsx new file mode 100644 index 0000000..a9296b7 --- /dev/null +++ b/web/src/pages/auth/SessionsPage.test.tsx @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// Bundle 2 Phase 8 — SessionsPage tests. Pins: +// - 403 ErrorState when caller lacks auth.session.list. +// - "Self" view renders the caller's sessions + self-pill on own row. +// - "All actors (admin)" toggle HIDDEN without auth.session.list.all. +// - "All actors (admin)" toggle SHOWN with auth.session.list.all. +// - Revoke button SHOWN for own session even without auth.session.revoke. +// - Revoke click calls revokeSession (after window.confirm). + +vi.mock('../../api/client', () => ({ + listSessions: vi.fn(), + revokeSession: vi.fn(), + authMe: vi.fn(), +})); + +import SessionsPage from './SessionsPage'; +import * as client from '../../api/client'; + +function renderWithProviders(ui: ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + {ui} + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + cleanup(); +}); + +const ownSession = { + id: 'sess-own', + actor_id: 'u-alice', + actor_type: 'User', + ip_address: '10.0.0.1', + user_agent: 'curl/8', + created_at: '2026-05-10T00:00:00Z', + last_seen_at: '2026-05-10T01:00:00Z', + idle_expires_at: '2026-05-10T02:00:00Z', + absolute_expires_at: '2026-05-11T00:00:00Z', + revoked: false, +}; + +const otherSession = { + id: 'sess-other', + actor_id: 'u-bob', + actor_type: 'User', + ip_address: '10.0.0.2', + user_agent: 'firefox', + created_at: '2026-05-10T00:00:00Z', + last_seen_at: '2026-05-10T01:00:00Z', + idle_expires_at: '2026-05-10T02:00:00Z', + absolute_expires_at: '2026-05-11T00:00:00Z', + revoked: false, +}; + +describe('SessionsPage', () => { + it('renders ErrorState when caller lacks auth.session.list', async () => { + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-x', + actor_type: 'User', + tenant_id: 't-default', + admin: false, + roles: [], + effective_permissions: [], + }); + renderWithProviders(); + await waitFor(() => { + expect(screen.queryByText(/auth\.session\.list/)).toBeTruthy(); + }); + }); + + it('renders own sessions with self-pill on caller row', async () => { + vi.mocked(client.listSessions).mockResolvedValue({ sessions: [ownSession] }); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-alice', + actor_type: 'User', + tenant_id: 't-default', + admin: false, + roles: ['r-viewer'], + effective_permissions: [{ permission: 'auth.session.list', scope_type: 'global' }], + }); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('session-row-sess-own')).toBeTruthy(); + }); + expect(screen.getByTestId('session-self-pill-sess-own')).toBeTruthy(); + // own session always shows revoke (own-bypass) regardless of auth.session.revoke. + expect(screen.getByTestId('session-revoke-sess-own')).toBeTruthy(); + }); + + it('hides "All actors" toggle when caller lacks auth.session.list.all', async () => { + vi.mocked(client.listSessions).mockResolvedValue({ sessions: [ownSession] }); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-alice', + actor_type: 'User', + tenant_id: 't-default', + admin: false, + roles: ['r-viewer'], + effective_permissions: [{ permission: 'auth.session.list', scope_type: 'global' }], + }); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('session-row-sess-own')).toBeTruthy(); + }); + expect(screen.getByTestId('sessions-view-self')).toBeTruthy(); + expect(screen.queryByTestId('sessions-view-all')).toBeNull(); + }); + + it('shows "All actors" toggle when caller has auth.session.list.all', async () => { + vi.mocked(client.listSessions).mockResolvedValue({ sessions: [ownSession] }); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-admin', + actor_type: 'User', + tenant_id: 't-default', + admin: true, + roles: ['r-admin'], + effective_permissions: [ + { permission: 'auth.session.list', scope_type: 'global' }, + { permission: 'auth.session.list.all', scope_type: 'global' }, + ], + }); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('sessions-view-all')).toBeTruthy(); + }); + }); + + it('hides revoke button on other-actor sessions without auth.session.revoke', async () => { + vi.mocked(client.listSessions).mockResolvedValue({ sessions: [ownSession, otherSession] }); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-alice', + actor_type: 'User', + tenant_id: 't-default', + admin: false, + roles: ['r-viewer'], + effective_permissions: [{ permission: 'auth.session.list', scope_type: 'global' }], + }); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('session-row-sess-other')).toBeTruthy(); + }); + expect(screen.getByTestId('session-revoke-sess-own')).toBeTruthy(); + expect(screen.queryByTestId('session-revoke-sess-other')).toBeNull(); + }); + + it('clicking revoke calls revokeSession after window.confirm', async () => { + vi.mocked(client.listSessions).mockResolvedValue({ sessions: [ownSession] }); + vi.mocked(client.revokeSession).mockResolvedValue(undefined); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-alice', + actor_type: 'User', + tenant_id: 't-default', + admin: false, + roles: ['r-viewer'], + effective_permissions: [{ permission: 'auth.session.list', scope_type: 'global' }], + }); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('session-revoke-sess-own')).toBeTruthy(); + }); + fireEvent.click(screen.getByTestId('session-revoke-sess-own')); + await waitFor(() => { + expect(client.revokeSession).toHaveBeenCalledWith('sess-own'); + }); + confirmSpy.mockRestore(); + }); +}); diff --git a/web/src/pages/auth/SessionsPage.tsx b/web/src/pages/auth/SessionsPage.tsx new file mode 100644 index 0000000..a6140f7 --- /dev/null +++ b/web/src/pages/auth/SessionsPage.tsx @@ -0,0 +1,203 @@ +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { listSessions, revokeSession, type SessionInfo } from '../../api/client'; +import { useAuthMe } from '../../hooks/useAuthMe'; +import PageHeader from '../../components/PageHeader'; +import ErrorState from '../../components/ErrorState'; + +// ============================================================================= +// Bundle 2 Phase 8 — SessionsPage. +// +// Renders the caller's active sessions by default. When the caller +// holds auth.session.list.all, an "All actors" toggle exposes the +// admin view (every active session in the tenant). +// +// Routes: +// /auth/sessions — admin all-actors view + own sessions toggle. +// API: +// GET /api/v1/auth/sessions (own; auth.session.list) +// GET /api/v1/auth/sessions?actor_id= (admin; auth.session.list.all) +// DELETE /api/v1/auth/sessions/{id} (own bypass + auth.session.revoke) +// +// Permission gating: page itself requires auth.session.list. Switch +// to all-actors view requires auth.session.list.all. Revoke action +// is shown for: (a) the caller's own sessions (own-bypass at the +// handler), AND (b) any session when caller holds auth.session.revoke. +// Server-side enforcement is the load-bearing layer; client-side +// hide is UX. +// ============================================================================= + +type ViewMode = 'self' | 'all'; + +export default function SessionsPage() { + const { data: me, hasPerm } = useAuthMe(); + const queryClient = useQueryClient(); + + const canList = hasPerm('auth.session.list'); + const canListAll = hasPerm('auth.session.list.all'); + const canRevokeAny = hasPerm('auth.session.revoke'); + + const [view, setView] = useState('self'); + const [filterActorID, setFilterActorID] = useState(''); + const [error, setError] = useState(null); + + // Effective actor_id query param when in admin view. + const effectiveActorID = view === 'all' ? filterActorID.trim() : ''; + + const { data, isLoading, error: loadErr } = useQuery({ + queryKey: ['sessions', view, effectiveActorID], + queryFn: () => + effectiveActorID ? listSessions(effectiveActorID, 'User') : listSessions(), + enabled: canList, + }); + + if (!canList) { + return ( +
+ + +
+ ); + } + + const handleRevoke = async (s: SessionInfo) => { + if (!window.confirm(`Revoke session ${s.id} for ${s.actor_id}? They will be logged out.`)) return; + try { + await revokeSession(s.id); + queryClient.invalidateQueries({ queryKey: ['sessions'] }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const callerActorID = me?.actor_id || ''; + + return ( +
+ + + {error && ( +
+ {error} +
+ )} + +
+ + {canListAll && ( + + )} + {view === 'all' && ( + setFilterActorID(e.target.value)} + placeholder="Filter by actor_id (e.g. u-alice)" + className="ml-2 flex-1 px-2 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" + data-testid="sessions-actor-id-filter" + /> + )} +
+ + {isLoading && ( +
+ Loading sessions… +
+ )} + {loadErr && } + + {data && data.sessions && data.sessions.length === 0 && ( +
+

No active sessions.

+
+ )} + + {data && data.sessions && data.sessions.length > 0 && ( +
+ + + + + + + + + + + + + {data.sessions.map((s: SessionInfo) => { + const isOwn = s.actor_id === callerActorID; + const showRevoke = isOwn || canRevokeAny; + return ( + + + + + + + + + ); + })} + +
Session IDActorIPLast seenAbsolute expiryActions
{s.id} + {s.actor_id} + ({s.actor_type}) + {isOwn && ( + + you + + )} + {s.ip_address || '—'} + {s.last_seen_at ? new Date(s.last_seen_at).toLocaleString() : '—'} + + {s.absolute_expires_at ? new Date(s.absolute_expires_at).toLocaleString() : '—'} + + {showRevoke && ( + + )} +
+
+ )} +
+ ); +}