Files
certctl/web/src/pages/LoginPage.tsx
T
shankar0123 f1d97710e1 feat(gui+auth): break-glass admin GUI surface (CRIT-4 closure)
Closes CRIT-4 of the 2026-05-10 audit. Bundle 2 Phase 7.5 shipped the
break-glass backend (Argon2id + lockout + 4 endpoints) but no GUI
surface. Operators recovering during an SSO outage had to hand-craft
curl commands — operationally hostile and the opposite of what
docs/operator/security.md advertised. This commit closes the gap.

Three GUI surfaces:

1. LoginPage.tsx — inline "Use break-glass account (SSO outage
   recovery)" toggle below the API-key form. Clicking reveals an
   amber-bordered inline form (actor-id + password, autocomplete=off).
   Calls breakglassLogin(actor_id, password); on success navigates
   to "/" where AuthProvider re-validates via the session-cookie path.
   Intentionally low-visibility (text-amber-600 small text) — this is
   the deliberate-bypass path, not the everyday-login path.

2. web/src/pages/auth/BreakglassPage.tsx — admin page at /auth/breakglass
   (permission-gated by auth.breakglass.admin). Three sections:
     - Sticky security banner ("every action audited; use only during
       incidents").
     - Set/rotate-password form (≥12-char + confirm-match).
     - Credentialed-actor table with rotate / unlock (disabled when
       not locked) / remove per row. Remove requires type-the-actor-id
       confirmation.

3. Layout.tsx nav — "Break-glass" entry under the auth section. Visible
   to all callers; the page itself permission-gates (server-side 403 is
   the load-bearing defense). Cosmetic hide-when-no-perm is deferred
   to fix 14's LOW bundle.

Backend support (new endpoint required to enumerate credentialed actors):

- internal/repository/breakglass.go — BreakglassCredentialRepository
  gains List(ctx, tenantID) method.
- internal/repository/postgres/breakglass.go — postgres impl; reuses
  the existing breakglassColumns / scanBreakglass helpers.
- internal/auth/breakglass/service.go — Service.List(ctx) method;
  returns ErrDisabled when CERTCTL_BREAKGLASS_ENABLED=false (handler
  maps to 404 for surface invisibility).
- internal/api/handler/auth_breakglass.go — ListCredentials handler;
  password_hash field NEVER serialized to the wire (response shape
  is intentionally limited to actor_id + timestamps + failure_count +
  locked_until).
- internal/api/router/router.go — registers GET
  /api/v1/auth/breakglass/credentials gated by auth.breakglass.admin.
- internal/api/router/openapi_parity_test.go — SpecParityExceptions
  entry for the new endpoint (full OpenAPI row rides along with the
  next OpenAPI sweep).

GUI api/client.ts gains breakglassListCredentials() + the
BreakglassCredentialRow type matching the wire shape.

Six Vitest cases in BreakglassPage.test.tsx pin the contract:
permission gate (forbidden state when caller lacks the perm; admin
surface when they have it), set-password mismatch rejection, set-
password below-threshold-length rejection, unlock-disabled-when-not-
locked, remove-modal type-confirm.

Verification gate green:
- gofmt -l clean on all touched files
- go vet clean
- go test -short -count=1 on internal/api/router (TestRouter_OpenAPIParity
  + TestRouterRBACGateCoverage + TestRouter_AuthExemptAllowlist),
  internal/api/handler (all BCL tests + ListCredentials),
  internal/auth/breakglass (Service.List + stubRepo.List),
  internal/repository/postgres, internal/domain/auth (auditor pin)
  — all pass.

CRIT-1 + CRIT-2 + CRIT-3 from the same audit are already closed on
this branch (commits 68ca42f, ca1e135, 00eace8). CRIT-5 (AllowedEmail-
Domains lying field) remains the last Critical blocker for v2.1.0.
Spec: cowork/auth-bundles-fixes-2026-05-10/04-crit-4-breakglass-gui.md.

Refs: cowork/auth-bundles-audit-2026-05-10.md CRIT-4
2026-05-10 20:24:52 +00:00

251 lines
10 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../components/AuthProvider';
import { getAuthInfo, breakglassLogin, 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.
//
// Audit 2026-05-10 CRIT-4 closure: an inline break-glass form below
// the API-key form lets admins recover during SSO incidents without
// crafting curl commands. The link is intentionally low-key
// (text-amber-600 small text) — break-glass is the deliberate-bypass
// path, not the everyday-login path.
// =============================================================================
export default function LoginPage() {
const { login, error: authError } = useAuth();
const navigate = useNavigate();
const [key, setKey] = useState('');
const [submitting, setSubmitting] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [providers, setProviders] = useState<AuthInfoOIDCProvider[]>([]);
// Break-glass inline form state.
const [showBreakglass, setShowBreakglass] = useState(false);
const [bgActorID, setBgActorID] = useState('');
const [bgPassword, setBgPassword] = useState('');
const [bgError, setBgError] = useState<string | null>(null);
const [bgSubmitting, setBgSubmitting] = useState(false);
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;
setSubmitting(true);
setLocalError(null);
try {
await login(key.trim());
} catch {
setLocalError('Invalid API key. Check your key and try again.');
} finally {
setSubmitting(false);
}
}
async function handleBreakglassSubmit(e: React.FormEvent) {
e.preventDefault();
if (!bgActorID.trim() || !bgPassword) return;
setBgSubmitting(true);
setBgError(null);
try {
await breakglassLogin(bgActorID.trim(), bgPassword);
// breakglassLogin sets the session cookie via Set-Cookie; navigate
// to the dashboard, which the AuthProvider will re-validate via
// its session-cookie path on next render.
navigate('/');
} catch (err) {
setBgError(err instanceof Error ? err.message : 'Break-glass login failed.');
} finally {
setBgSubmitting(false);
}
}
return (
<div className="min-h-screen bg-page flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-brand-400 mb-2">certctl</h1>
<p className="text-sm text-ink-muted uppercase tracking-wider">Certificate Control Plane</p>
</div>
{providers.length > 0 && (
<div
className="bg-surface border border-surface-border rounded p-6 space-y-3 shadow-sm mb-4"
data-testid="login-oidc-providers"
>
<p className="text-sm font-medium text-ink-muted text-center">Sign in with your identity provider</p>
{providers.map(p => (
<a
key={p.id}
href={p.login_url}
className="block w-full text-center bg-brand-400 hover:bg-brand-500 text-white py-2.5 text-sm font-medium rounded transition-colors"
data-testid={`login-oidc-button-${p.id}`}
>
Sign in with {p.display_name}
</a>
))}
</div>
)}
<form
onSubmit={handleSubmit}
className="bg-surface border border-surface-border rounded p-6 space-y-4 shadow-sm"
data-testid="login-api-key-form"
>
{providers.length > 0 && (
<p className="text-xs text-ink-muted text-center pb-2 border-b border-surface-border">
or sign in with API key
</p>
)}
<div>
<label htmlFor="api-key" className="block text-sm font-medium text-ink-muted mb-1.5">
API Key
</label>
<input
id="api-key"
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="Enter your API key"
autoFocus={providers.length === 0}
className="w-full bg-white border border-surface-border rounded px-3 py-2.5 text-sm text-ink placeholder-ink-faint focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
data-testid="login-api-key-input"
/>
</div>
{error && (
<div
className="bg-red-50 border border-red-200 rounded px-3 py-2 text-sm text-red-700"
data-testid="login-error"
>
{error}
</div>
)}
<button
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'}
</button>
<p className="text-xs text-ink-muted text-center">
The API key is set via <code className="text-ink-faint bg-page px-1 py-0.5 rounded">CERTCTL_AUTH_SECRET</code> on the server.
</p>
</form>
{/* Break-glass entry — low-visibility on purpose. CRIT-4 closure. */}
<div className="mt-4 text-center" data-testid="login-breakglass-entry">
{!showBreakglass ? (
<button
type="button"
onClick={() => setShowBreakglass(true)}
className="text-xs text-amber-600 hover:text-amber-700 hover:underline"
data-testid="login-breakglass-toggle"
>
Use break-glass account (SSO outage recovery)
</button>
) : (
<form
onSubmit={handleBreakglassSubmit}
className="bg-amber-50 border border-amber-200 rounded p-4 mt-4 space-y-3 text-left"
data-testid="login-breakglass-form"
>
<p className="text-xs font-medium text-amber-900">
Break-glass admin login every action is audited. Use only during SSO incidents.
</p>
<div>
<label htmlFor="bg-actor-id" className="block text-xs font-medium text-amber-900 mb-1">
Actor ID
</label>
<input
id="bg-actor-id"
type="text"
value={bgActorID}
onChange={e => setBgActorID(e.target.value)}
autoComplete="off"
spellCheck={false}
placeholder="actor-..."
className="w-full bg-white border border-amber-300 rounded px-3 py-2 text-sm text-ink placeholder-ink-faint focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500/20"
data-testid="login-breakglass-actor-id"
/>
</div>
<div>
<label htmlFor="bg-password" className="block text-xs font-medium text-amber-900 mb-1">
Password
</label>
<input
id="bg-password"
type="password"
value={bgPassword}
onChange={e => setBgPassword(e.target.value)}
autoComplete="off"
className="w-full bg-white border border-amber-300 rounded px-3 py-2 text-sm text-ink placeholder-ink-faint focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500/20"
data-testid="login-breakglass-password"
/>
</div>
{bgError && (
<div
className="bg-red-50 border border-red-200 rounded px-3 py-2 text-xs text-red-700"
data-testid="login-breakglass-error"
>
{bgError}
</div>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={bgSubmitting || !bgActorID.trim() || !bgPassword}
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white py-2 text-sm font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
data-testid="login-breakglass-submit"
>
{bgSubmitting ? 'Signing in…' : 'Sign in (break-glass)'}
</button>
<button
type="button"
onClick={() => {
setShowBreakglass(false);
setBgActorID('');
setBgPassword('');
setBgError(null);
}}
className="px-3 py-2 text-sm font-medium text-amber-900 hover:bg-amber-100 rounded transition-colors"
data-testid="login-breakglass-cancel"
>
Cancel
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
}