import { useState, useEffect, useRef } from "react"; import { Plus, Key, MoreHorizontal, Copy, Trash2, Loader2, AlertCircle, CheckCircle, Network, Terminal, Check, Globe, RefreshCw, Info, Pencil, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { api, OIDCClient, OIDCClientWithSecret } from "@/lib/api"; import { useToast } from "@/hooks/use-toast"; import { useOrg } from "@/contexts/OrgContext"; // Derive issuer base URL from the API base const ISSUER_URL = (import.meta.env.VITE_API_BASE_URL ?? "http://localhost:5000/api/v1") .replace(/\/api\/v1\/?$/, ""); /** Generate a cryptographically random 32-byte base64url cookie secret. */ function generateCookieSecret(): string { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); // Standard base64, then make it URL-safe (oauth2-proxy accepts both) return btoa(String.fromCharCode(...bytes)); } function buildProxyConfig( clientId: string, clientSecret: string, proxyHost: string, upstream: string, setAuthHeader: boolean, setXAuthHeader: boolean, cookieSecret: string, ) { // Normalise the proxy host — add https:// if no scheme given const normalizedHost = /^https?:\/\//i.test(proxyHost) ? proxyHost.replace(/\/$/, "") : `https://${proxyHost.replace(/\/$/, "")}`; // cookie_secure must be true for https, false for plain http const cookieSecure = normalizedHost.startsWith("https://"); const lines = [ `provider = "oidc"`, `oidc_issuer_url = "${ISSUER_URL}"`, `client_id = "${clientId}"`, `client_secret = "${clientSecret}"`, `redirect_url = "${normalizedHost}/oauth2/callback"`, `scope = "openid profile email"`, `cookie_secret = "${cookieSecret}"`, `cookie_secure = ${cookieSecure}`, `upstream = "${upstream || "http://127.0.0.1:8080/"}"`, ]; if (setAuthHeader) lines.push(`set_authorization_header = true`); if (setXAuthHeader) lines.push(`set_x_auth_request_header = true`); return lines.join("\n"); } function useCopyButton() { const [copied, setCopied] = useState(false); const copy = (text: string) => { navigator.clipboard.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }; return { copied, copy }; } type DialogMode = "generic" | "proxy" | null; interface NewSecretState { clientId: string; secret: string; proxyHost?: string; proxyUpstream?: string; proxySetAuthHeader?: boolean; proxySetXAuthHeader?: boolean; proxyCookieSecret?: string; isProxy: boolean; } export default function OIDCClientsPage() { const { toast } = useToast(); const { selectedOrgId: orgId } = useOrg(); const { copy: copySecret, copied: secretCopied } = useCopyButton(); const { copy: copyConfig, copied: configCopied } = useCopyButton(); const [clients, setClients] = useState([]); const [isLoading, setIsLoading] = useState(true); const [dialogMode, setDialogMode] = useState(null); const [isCreating, setIsCreating] = useState(false); const [newSecret, setNewSecret] = useState(null); // Generic form const nameRef = useRef(null); const urisRef = useRef(null); // Proxy form const proxyNameRef = useRef(null); const proxyHostRef = useRef(null); const proxyUpstreamRef = useRef(null); const [proxySetAuthHeader, setProxySetAuthHeader] = useState(true); const [proxySetXAuthHeader, setProxySetXAuthHeader] = useState(true); // Edit state const [editingClient, setEditingClient] = useState(null); const [editName, setEditName] = useState(""); const [editUris, setEditUris] = useState(""); const [isSavingEdit, setIsSavingEdit] = useState(false); useEffect(() => { if (!orgId) { setIsLoading(false); return; } setIsLoading(true); api.organizations.getClients(orgId) .then((data) => setClients(data.clients)) .catch(() => toast({ title: "Error", description: "Failed to load OIDC clients.", variant: "destructive" })) .finally(() => setIsLoading(false)); }, [orgId]); const handleCreate = async () => { if (!orgId) return; let name: string; let uris: string[]; let proxyHost: string | undefined; if (dialogMode === "generic") { name = nameRef.current?.value.trim() ?? ""; uris = (urisRef.current?.value ?? "").split(/[\n,]+/).map((u) => u.trim()).filter(Boolean); if (!name || !uris.length) return; } else { name = proxyNameRef.current?.value.trim() ?? ""; proxyHost = proxyHostRef.current?.value.trim() ?? ""; if (!name || !proxyHost) return; // Normalise scheme for the registered redirect URI (must match config) const normalizedHost = /^https?:\/\//i.test(proxyHost) ? proxyHost.replace(/\/$/, "") : `https://${proxyHost.replace(/\/$/, "")}`; uris = [`${normalizedHost}/oauth2/callback`]; } setIsCreating(true); try { const result = await api.organizations.createClient(orgId, name, uris); const created = result.client as OIDCClientWithSecret; setClients((prev) => [...prev, created]); setNewSecret({ clientId: created.client_id, secret: created.client_secret, proxyHost, proxyUpstream: proxyUpstreamRef.current?.value.trim() || "http://127.0.0.1:8080/", proxySetAuthHeader, proxySetXAuthHeader, proxyCookieSecret: dialogMode === "proxy" ? generateCookieSecret() : undefined, isProxy: dialogMode === "proxy", }); setDialogMode(null); } catch { toast({ title: "Error", description: "Failed to create client.", variant: "destructive" }); } finally { setIsCreating(false); } }; const handleDelete = async (clientId: string) => { if (!orgId) return; try { await api.organizations.deleteClient(orgId, clientId); setClients((prev) => prev.filter((c) => c.id !== clientId)); toast({ title: "Client deleted" }); } catch { toast({ title: "Error", description: "Failed to delete client.", variant: "destructive" }); } }; const openEditDialog = (client: OIDCClient) => { setEditingClient(client); setEditName(client.name); setEditUris((client.redirect_uris ?? []).join("\n")); }; const handleSaveEdit = async () => { if (!orgId || !editingClient) return; const name = editName.trim(); const uris = editUris.split(/[\n,]+/).map((u) => u.trim()).filter(Boolean); if (!name || !uris.length) return; setIsSavingEdit(true); try { const result = await api.organizations.updateClient(orgId, editingClient.id, { name, redirect_uris: uris }); setClients((prev) => prev.map((c) => (c.id === editingClient.id ? result.client : c)) ); setEditingClient(null); toast({ title: "Client updated" }); } catch { toast({ title: "Error", description: "Failed to update client.", variant: "destructive" }); } finally { setIsSavingEdit(false); } }; const proxyConfig = newSecret?.isProxy && newSecret.proxyHost ? buildProxyConfig( newSecret.clientId, newSecret.secret, newSecret.proxyHost, newSecret.proxyUpstream ?? "http://127.0.0.1:8080/", newSecret.proxySetAuthHeader ?? true, newSecret.proxySetXAuthHeader ?? true, newSecret.proxyCookieSecret ?? generateCookieSecret(), ) : null; return (
{/* Header */}

OIDC Clients

Applications that authenticate via Secuird

{/* One-time secret banner */} {newSecret && (

Client created — save your secret now

This will not be shown again.

{/* Secret row */}

Client secret

{newSecret.secret}
{/* oauth2-proxy config snippet */} {proxyConfig && (

oauth2-proxy config

                        {proxyConfig}
                      
)}
)} {/* Client list */} {isLoading ? (
) : clients.length === 0 ? (

No OIDC clients yet

Register an app to let it authenticate via Secuird

) : (
{clients.map((client) => (

{client.name}

{client.client_id}
{(client.scopes ?? []).map((scope) => ( {scope} ))}
openEditDialog(client)}> Edit handleDelete(client.id)} > Delete
Created {new Date(client.created_at).toLocaleDateString()} {(client.redirect_uris ?? []).length} redirect URI{(client.redirect_uris ?? []).length !== 1 ? "s" : ""}
))}
)} {/* Create dialog */} { if (!open) setDialogMode(null); }}> Add OIDC Client Register an application to authenticate via Secuird setDialogMode(v as DialogMode)} className="mt-2" > Generic app oauth2-proxy {/* Generic tab */}