diff --git a/CLAUDE.md b/CLAUDE.md index 42e7341..e6b37b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,9 +31,12 @@ You are my long-term copilot for building certctl — a self-hosted certificate - [x] Test suite — 120 tests across service layer (63), handler layer (46), and integration (11 subtests) - [x] Input validation — centralized validators for common name, CSR PEM, policy type/severity, string length - [x] GitHub Actions CI — parallel Go (build, vet, test+coverage) and Frontend (tsc, vite build) jobs +- [x] API key auth enforced by default — SHA-256 hashed keys, constant-time comparison, Bearer token middleware +- [x] Token bucket rate limiting — configurable RPS/burst, 429 responses with Retry-After header +- [x] Configurable CORS — per-origin allowlist or wildcard, preflight caching +- [x] GUI auth flow — login screen, auth context, 401 auto-redirect, logout button ### What's NOT Wired Up Yet (Pre-v1.0 Gaps) -- [ ] **API authentication enforced**: Auth types exist but demo runs with `CERTCTL_AUTH_TYPE=none`. No rate limiting. - [ ] **Agent-side key generation**: V1 uses server-side key generation for Local CA (pragmatic for dev/demo). Must move to agents before v1.0. - [ ] **End-to-end test hardening**: Handler tests only cover 2 of 7 files. No negative-path integration tests (issuer down, malformed certs, DB failures). No scheduler or connector tests. No frontend tests. @@ -62,32 +65,15 @@ Fixed nginx.go format string errors, added centralized input validation (validat ### M6: Functional GUI + CI ✅ All views wired to real API: agent detail page with heartbeat status + capabilities + recent jobs, audit trail with time range/actor/resource filters, notifications with grouped-by-cert view + read/unread state + mark-read mutations, policies with severity summary bar + config preview, new issuers and targets list views. GitHub Actions CI with parallel Go (build, vet, test+coverage) and Frontend (tsc, vite build) jobs. Makefile updated with test-cover and frontend-build targets. +### M7: Auth + Rate Limiting ✅ +API key auth middleware with SHA-256 hashing and constant-time comparison. `CERTCTL_AUTH_TYPE=api-key` enforced by default; `none` requires explicit opt-in with log warning. Token bucket rate limiter (configurable via `CERTCTL_RATE_LIMIT_RPS` / `CERTCTL_RATE_LIMIT_BURST`). Configurable CORS via `CERTCTL_CORS_ORIGINS`. GUI: login page with API key entry, AuthProvider context, automatic 401 redirect, logout button in sidebar. Auth info endpoint (`GET /api/v1/auth/info`) served without auth so GUI can detect auth mode. Auth check endpoint (`GET /api/v1/auth/check`) validates credentials. + --- ## V1 Roadmap: Ship a Functional Product The principle: **every backend feature ships with its corresponding GUI surface.** The GUI is where ops teams spend 80% of their time — it must be an operational tool, not a demo viewer. -### M7: Auth + Rate Limiting -**Goal**: Make the API production-safe for shared/team environments. - -**Authentication:** -- API key auth middleware enforced by default (`CERTCTL_AUTH_TYPE=api-key`) -- Key generation and hashing (bcrypt/argon2) for stored keys -- Auth bypass only with explicit `CERTCTL_AUTH_TYPE=none` flag -- GUI: API key entry/login screen, key passed via `Authorization: Bearer` header - -**Rate limiting:** -- Token bucket rate limiter on all API endpoints (`golang.org/x/time/rate`) -- Configurable per-endpoint or global limits via `CERTCTL_RATE_LIMIT_RPS` -- 429 Too Many Requests response with `Retry-After` header - -**CORS:** -- Configurable allowed origins for dashboard (`CERTCTL_CORS_ORIGINS`) -- Sensible defaults for same-origin deployment - -**Deliverables**: Auth enforced by default, rate limits active, CORS configured. certctl deployable in shared environments. - ### M8: Agent-Side Key Generation **Goal**: Private keys never leave agent infrastructure. This is the crypto architecture gate for v1.0. diff --git a/cmd/server/main.go b/cmd/server/main.go index 08655df..c376d59 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -121,7 +121,7 @@ func main() { ownerHandler := handler.NewOwnerHandler(ownerService) auditHandler := handler.NewAuditHandler(auditService) notificationHandler := handler.NewNotificationHandler(notificationService) - healthHandler := handler.NewHealthHandler() + healthHandler := handler.NewHealthHandler(cfg.Auth.Type) logger.Info("initialized all handlers") // Create context with cancellation @@ -166,13 +166,48 @@ func main() { ) logger.Info("registered all API handlers") - // Apply middleware to API router - apiHandler := middleware.Chain( - apiRouter, + // Build middleware stack + authMiddleware := middleware.NewAuth(middleware.AuthConfig{ + Type: cfg.Auth.Type, + Secret: cfg.Auth.Secret, + }) + corsMiddleware := middleware.NewCORS(middleware.CORSConfig{ + AllowedOrigins: cfg.CORS.AllowedOrigins, + }) + + middlewareStack := []func(http.Handler) http.Handler{ middleware.RequestID, middleware.Logging, middleware.Recovery, - ) + corsMiddleware, + authMiddleware, + } + + // Add rate limiter if enabled + if cfg.RateLimit.Enabled { + rateLimiter := middleware.NewRateLimiter(middleware.RateLimitConfig{ + RPS: cfg.RateLimit.RPS, + BurstSize: cfg.RateLimit.BurstSize, + }) + middlewareStack = []func(http.Handler) http.Handler{ + middleware.RequestID, + middleware.Logging, + middleware.Recovery, + rateLimiter, + corsMiddleware, + authMiddleware, + } + logger.Info("rate limiting enabled", "rps", cfg.RateLimit.RPS, "burst", cfg.RateLimit.BurstSize) + } + + if cfg.Auth.Type == "none" { + logger.Warn("authentication disabled (CERTCTL_AUTH_TYPE=none) — not suitable for production") + } else { + logger.Info("authentication enabled", "type", cfg.Auth.Type) + } + + // Apply middleware to API router + apiHandler := middleware.Chain(apiRouter, middlewareStack...) // Wrap with dashboard static file serving // Vite builds to web/dist/; fall back to web/ for legacy single-file SPA diff --git a/internal/api/handler/health.go b/internal/api/handler/health.go index 252b1a8..0e2e2c6 100644 --- a/internal/api/handler/health.go +++ b/internal/api/handler/health.go @@ -5,11 +5,13 @@ import ( ) // HealthHandler handles health and readiness check endpoints. -type HealthHandler struct{} +type HealthHandler struct { + AuthType string // "api-key", "jwt", "none" +} // NewHealthHandler creates a new HealthHandler. -func NewHealthHandler() HealthHandler { - return HealthHandler{} +func NewHealthHandler(authType string) HealthHandler { + return HealthHandler{AuthType: authType} } // Health responds with a simple health check indicating the service is alive. @@ -41,3 +43,21 @@ func (h HealthHandler) Ready(w http.ResponseWriter, r *http.Request) { JSON(w, http.StatusOK, response) } + +// AuthInfo responds with the server's authentication configuration. +// This lets the GUI know whether to show a login screen. +// GET /api/v1/auth/info (served without auth middleware) +func (h HealthHandler) AuthInfo(w http.ResponseWriter, r *http.Request) { + response := map[string]interface{}{ + "auth_type": h.AuthType, + "required": h.AuthType != "none", + } + JSON(w, http.StatusOK, response) +} + +// AuthCheck returns 200 if the request has valid auth credentials. +// The auth middleware runs before this handler, so reaching here means auth passed. +// GET /api/v1/auth/check +func (h HealthHandler) AuthCheck(w http.ResponseWriter, r *http.Request) { + JSON(w, http.StatusOK, map[string]string{"status": "authenticated"}) +} diff --git a/internal/api/middleware/middleware.go b/internal/api/middleware/middleware.go index 7835de1..3da37d2 100644 --- a/internal/api/middleware/middleware.go +++ b/internal/api/middleware/middleware.go @@ -2,8 +2,12 @@ package middleware import ( "context" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" "log" "net/http" + "sync" "time" "github.com/google/uuid" @@ -48,36 +52,178 @@ func Recovery(next http.Handler) http.Handler { if err := recover(); err != nil { requestID := getRequestID(r.Context()) log.Printf("[%s] PANIC: %v", requestID, err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + http.Error(w, `{"error":"Internal Server Error"}`, http.StatusInternalServerError) } }() next.ServeHTTP(w, r) }) } -// Auth middleware is a placeholder that checks the Authorization header and extracts user information. -// In production, this would validate tokens, verify signatures, etc. -func Auth(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - // For now, allow requests without auth (placeholder) - // In production, enforce auth on protected routes - next.ServeHTTP(w, r) - return - } +// HashAPIKey computes the SHA-256 hash of an API key for secure storage. +// We use SHA-256 rather than bcrypt because API keys are high-entropy +// random strings (not user-chosen passwords), so rainbow tables and +// brute-force attacks are not a practical concern. +func HashAPIKey(key string) string { + h := sha256.Sum256([]byte(key)) + return hex.EncodeToString(h[:]) +} - // Simple stub: just extract user ID from Bearer token (format: "Bearer ") - // This is NOT secure and for development only - if len(authHeader) > 7 && authHeader[:7] == "Bearer " { - userID := authHeader[7:] - ctx := context.WithValue(r.Context(), UserKey{}, userID) +// AuthConfig holds configuration for the Auth middleware. +type AuthConfig struct { + Type string // "api-key", "jwt", "none" + Secret string // The raw API key (server compares against this) +} + +// NewAuth creates an authentication middleware based on config. +// When Type is "none", all requests pass through (demo/development mode). +// When Type is "api-key", requests must include a valid Bearer token. +func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler { + if cfg.Type == "none" { + return func(next http.Handler) http.Handler { + return next + } + } + + // Pre-compute hash of the expected key for constant-time comparison + expectedHash := HashAPIKey(cfg.Secret) + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("WWW-Authenticate", `Bearer realm="certctl"`) + http.Error(w, `{"error":"Authorization header required"}`, http.StatusUnauthorized) + return + } + + // Extract Bearer token + if len(authHeader) < 8 || authHeader[:7] != "Bearer " { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + http.Error(w, `{"error":"Invalid Authorization header format, expected: Bearer "}`, http.StatusUnauthorized) + return + } + + token := authHeader[7:] + tokenHash := HashAPIKey(token) + + // Constant-time comparison to prevent timing attacks + if subtle.ConstantTimeCompare([]byte(tokenHash), []byte(expectedHash)) != 1 { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + http.Error(w, `{"error":"Invalid API key"}`, http.StatusUnauthorized) + return + } + + // Store the authenticated identity in context + ctx := context.WithValue(r.Context(), UserKey{}, "api-key-user") next.ServeHTTP(w, r.WithContext(ctx)) - return - } + }) + } +} - http.Error(w, "Invalid Authorization header", http.StatusUnauthorized) - }) +// RateLimitConfig holds configuration for the rate limiter. +type RateLimitConfig struct { + RPS float64 // Requests per second + BurstSize int // Maximum burst size +} + +// NewRateLimiter creates a token bucket rate limiting middleware. +// Uses a simple token bucket: tokens refill at RPS rate, burst allows short spikes. +func NewRateLimiter(cfg RateLimitConfig) func(http.Handler) http.Handler { + limiter := &tokenBucket{ + rate: cfg.RPS, + burstSize: float64(cfg.BurstSize), + tokens: float64(cfg.BurstSize), + lastRefill: time.Now(), + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !limiter.allow() { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Retry-After", "1") + http.Error(w, `{"error":"Rate limit exceeded"}`, http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// tokenBucket implements a simple thread-safe token bucket rate limiter. +// This avoids importing golang.org/x/time/rate to keep dependencies minimal. +type tokenBucket struct { + mu sync.Mutex + rate float64 // tokens per second + burstSize float64 // max tokens + tokens float64 // current tokens + lastRefill time.Time // last refill time +} + +func (tb *tokenBucket) allow() bool { + tb.mu.Lock() + defer tb.mu.Unlock() + + now := time.Now() + elapsed := now.Sub(tb.lastRefill).Seconds() + tb.tokens += elapsed * tb.rate + if tb.tokens > tb.burstSize { + tb.tokens = tb.burstSize + } + tb.lastRefill = now + + if tb.tokens < 1 { + return false + } + tb.tokens-- + return true +} + +// CORSConfig holds configuration for the CORS middleware. +type CORSConfig struct { + AllowedOrigins []string // Allowed origins; empty = same-origin only +} + +// NewCORS creates a CORS middleware with configurable allowed origins. +// If no origins are configured, same-origin requests are allowed by default. +// If ["*"] is configured, all origins are allowed (development/demo mode). +func NewCORS(cfg CORSConfig) func(http.Handler) http.Handler { + allowAll := false + originSet := make(map[string]bool) + for _, o := range cfg.AllowedOrigins { + if o == "*" { + allowAll = true + } + originSet[o] = true + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + if allowAll { + w.Header().Set("Access-Control-Allow-Origin", "*") + } else if origin != "" && originSet[origin] { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + } else if len(cfg.AllowedOrigins) == 0 && origin != "" { + // No config = permissive same-origin default for single-host deployments + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + } + + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID") + w.Header().Set("Access-Control-Max-Age", "86400") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) + } } // ContentType middleware sets the Content-Type header to application/json. @@ -89,6 +235,7 @@ func ContentType(next http.Handler) http.Handler { } // CORS middleware adds CORS headers to allow cross-origin requests. +// Deprecated: Use NewCORS for configurable origins. Kept for health endpoints. func CORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 53adf5a..47d985d 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -57,7 +57,7 @@ func (r *Router) RegisterHandlers( notifications handler.NotificationHandler, health handler.HealthHandler, ) { - // Health endpoints (no middleware) + // Health endpoints (no auth middleware — must always be accessible) r.mux.Handle("GET /health", middleware.Chain( http.HandlerFunc(health.Health), middleware.CORS, @@ -68,6 +68,14 @@ func (r *Router) RegisterHandlers( middleware.CORS, middleware.ContentType, )) + // Auth info endpoint (no auth middleware — GUI needs this before login) + r.mux.Handle("GET /api/v1/auth/info", middleware.Chain( + http.HandlerFunc(health.AuthInfo), + middleware.CORS, + middleware.ContentType, + )) + // Auth check endpoint (uses full middleware chain via r.Register) + r.Register("GET /api/v1/auth/check", http.HandlerFunc(health.AuthCheck)) // Certificates routes: /api/v1/certificates r.Register("GET /api/v1/certificates", http.HandlerFunc(certificates.ListCertificates)) diff --git a/internal/config/config.go b/internal/config/config.go index 52f2fa0..0077ddb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,8 @@ type Config struct { Scheduler SchedulerConfig Log LogConfig Auth AuthConfig + RateLimit RateLimitConfig + CORS CORSConfig } // ServerConfig contains HTTP server configuration. @@ -51,6 +53,18 @@ type AuthConfig struct { Secret string // Secret key for signing (if applicable) } +// RateLimitConfig contains rate limiting configuration. +type RateLimitConfig struct { + Enabled bool + RPS float64 // Requests per second + BurstSize int // Maximum burst size +} + +// CORSConfig contains CORS configuration. +type CORSConfig struct { + AllowedOrigins []string // Allowed origins; empty = same-origin only; ["*"] = all +} + // Load reads configuration from environment variables and returns a Config. // Environment variables must have the CERTCTL_ prefix. // Example: CERTCTL_SERVER_HOST, CERTCTL_DATABASE_URL, etc. @@ -79,6 +93,14 @@ func Load() (*Config, error) { Type: getEnv("CERTCTL_AUTH_TYPE", "api-key"), Secret: getEnv("CERTCTL_AUTH_SECRET", ""), }, + RateLimit: RateLimitConfig{ + Enabled: getEnvBool("CERTCTL_RATE_LIMIT_ENABLED", true), + RPS: getEnvFloat("CERTCTL_RATE_LIMIT_RPS", 50), + BurstSize: getEnvInt("CERTCTL_RATE_LIMIT_BURST", 100), + }, + CORS: CORSConfig{ + AllowedOrigins: getEnvList("CERTCTL_CORS_ORIGINS", nil), + }, } if err := cfg.Validate(); err != nil { @@ -192,6 +214,67 @@ func getEnvDuration(key string, defaultValue time.Duration) time.Duration { return defaultValue } +// getEnvBool reads a boolean environment variable. +func getEnvBool(key string, defaultValue bool) bool { + if value := os.Getenv(key); value != "" { + return value == "true" || value == "1" || value == "yes" + } + return defaultValue +} + +// getEnvFloat reads a float64 environment variable. +func getEnvFloat(key string, defaultValue float64) float64 { + if value := os.Getenv(key); value != "" { + f, err := strconv.ParseFloat(value, 64) + if err != nil { + return defaultValue + } + return f + } + return defaultValue +} + +// getEnvList reads a comma-separated list environment variable. +func getEnvList(key string, defaultValue []string) []string { + if value := os.Getenv(key); value != "" { + var result []string + for _, s := range splitComma(value) { + s = trimSpace(s) + if s != "" { + result = append(result, s) + } + } + return result + } + return defaultValue +} + +// splitComma splits a string by commas (no strings import needed). +func splitComma(s string) []string { + var parts []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == ',' { + parts = append(parts, s[start:i]) + start = i + 1 + } + } + parts = append(parts, s[start:]) + return parts +} + +// trimSpace trims leading/trailing whitespace. +func trimSpace(s string) string { + start, end := 0, len(s) + for start < end && (s[start] == ' ' || s[start] == '\t') { + start++ + } + for end > start && (s[end-1] == ' ' || s[end-1] == '\t') { + end-- + } + return s[start:end] +} + // GetLogLevel returns the appropriate slog.Level from the configured log level. func (c *Config) GetLogLevel() slog.Level { switch c.Log.Level { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 4cb218a..c75c5cb 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -2,11 +2,36 @@ import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEv const BASE = '/api/v1'; +// API key stored in memory (not localStorage for security) +let apiKey: string | null = null; + +export function setApiKey(key: string | null) { + apiKey = key; +} + +export function getApiKey(): string | null { + return apiKey; +} + +function authHeaders(): Record { + const headers: Record = { 'Content-Type': 'application/json' }; + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + return headers; +} + async function fetchJSON(url: string, init?: RequestInit): Promise { const res = await fetch(url, { - headers: { 'Content-Type': 'application/json', ...init?.headers }, + headers: { ...authHeaders(), ...init?.headers }, ...init, }); + if (res.status === 401) { + // Trigger re-auth + const event = new CustomEvent('certctl:auth-required'); + window.dispatchEvent(event); + throw new Error('Authentication required'); + } if (!res.ok) { const body = await res.json().catch(() => ({ message: res.statusText })); throw new Error(body.message || body.error || `HTTP ${res.status}`); @@ -14,6 +39,19 @@ async function fetchJSON(url: string, init?: RequestInit): Promise { return res.json(); } +// Auth +export const getAuthInfo = () => + fetch(`${BASE}/auth/info`, { headers: { 'Content-Type': 'application/json' } }) + .then(r => r.json() as Promise<{ auth_type: string; required: boolean }>); + +export const checkAuth = (key: string) => + fetch(`${BASE}/auth/check`, { + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${key}` }, + }).then(r => { + if (!r.ok) throw new Error('Invalid API key'); + return r.json() as Promise<{ status: string }>; + }); + // Certificates export const getCertificates = (params: Record = {}) => { const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString(); diff --git a/web/src/components/AuthGate.tsx b/web/src/components/AuthGate.tsx new file mode 100644 index 0000000..e3ea8e7 --- /dev/null +++ b/web/src/components/AuthGate.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; +import { useAuth } from './AuthProvider'; +import LoginPage from '../pages/LoginPage'; + +export default function AuthGate({ children }: { children: ReactNode }) { + const { loading, authRequired, authenticated } = useAuth(); + + if (loading) { + return ( +
+
+

certctl

+

Connecting...

+
+
+ ); + } + + if (authRequired && !authenticated) { + return ; + } + + return <>{children}; +} diff --git a/web/src/components/AuthProvider.tsx b/web/src/components/AuthProvider.tsx new file mode 100644 index 0000000..fd25fbf --- /dev/null +++ b/web/src/components/AuthProvider.tsx @@ -0,0 +1,87 @@ +import { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import type { ReactNode } from 'react'; +import { getAuthInfo, checkAuth, setApiKey } from '../api/client'; + +interface AuthState { + loading: boolean; + authRequired: boolean; + authenticated: boolean; + authType: string; + login: (key: string) => Promise; + logout: () => void; + error: string | null; +} + +const AuthContext = createContext({ + loading: true, + authRequired: false, + authenticated: false, + authType: 'none', + login: async () => {}, + logout: () => {}, + error: null, +}); + +export function useAuth() { + return useContext(AuthContext); +} + +export default function AuthProvider({ children }: { children: ReactNode }) { + const [loading, setLoading] = useState(true); + const [authRequired, setAuthRequired] = useState(false); + const [authenticated, setAuthenticated] = useState(false); + const [authType, setAuthType] = useState('none'); + const [error, setError] = useState(null); + + // Check if server requires auth on mount + useEffect(() => { + getAuthInfo() + .then((info) => { + setAuthType(info.auth_type); + setAuthRequired(info.required); + if (!info.required) { + setAuthenticated(true); + } + }) + .catch(() => { + // If auth/info fails, assume no auth required (server may be old version) + setAuthenticated(true); + }) + .finally(() => setLoading(false)); + }, []); + + // Listen for 401 events from the API client + useEffect(() => { + const handler = () => { + setAuthenticated(false); + setApiKey(null); + setError('Session expired. Please re-enter your API key.'); + }; + window.addEventListener('certctl:auth-required', handler); + return () => window.removeEventListener('certctl:auth-required', handler); + }, []); + + const login = useCallback(async (key: string) => { + setError(null); + try { + await checkAuth(key); + setApiKey(key); + setAuthenticated(true); + } catch { + setError('Invalid API key'); + throw new Error('Invalid API key'); + } + }, []); + + const logout = useCallback(() => { + setApiKey(null); + setAuthenticated(false); + setError(null); + }, []); + + return ( + + {children} + + ); +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index fb05e8f..db6fe56 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -1,4 +1,5 @@ import { NavLink, Outlet } from 'react-router-dom'; +import { useAuth } from './AuthProvider'; const nav = [ { to: '/', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4' }, @@ -21,6 +22,8 @@ function Icon({ d }: { d: string }) { } export default function Layout() { + const { authRequired, logout } = useAuth(); + return (
{/* Sidebar */} @@ -48,8 +51,19 @@ export default function Layout() { ))} -
- certctl v1.0-dev +
+ certctl v1.0-dev + {authRequired && ( + + )}
diff --git a/web/src/main.tsx b/web/src/main.tsx index b63ad28..5604ce3 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -2,6 +2,8 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import AuthProvider from './components/AuthProvider'; +import AuthGate from './components/AuthGate'; import Layout from './components/Layout'; import DashboardPage from './pages/DashboardPage'; import CertificatesPage from './pages/CertificatesPage'; @@ -29,23 +31,27 @@ const queryClient = new QueryClient({ createRoot(document.getElementById('root')!).render( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..5f33ad2 --- /dev/null +++ b/web/src/pages/LoginPage.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react'; +import { useAuth } from '../components/AuthProvider'; + +export default function LoginPage() { + const { login, error: authError } = useAuth(); + const [key, setKey] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [localError, setLocalError] = useState(null); + + const error = localError || authError; + + 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); + } + } + + return ( +
+
+
+

certctl

+

Certificate Control Plane

+
+ +
+
+ + setKey(e.target.value)} + placeholder="Enter your API key" + autoFocus + className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2.5 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + /> +
+ + {error && ( +
+ {error} +
+ )} + + + +

+ The API key is set via CERTCTL_AUTH_SECRET on the server. +

+
+
+
+ ); +}