Files
gatehouse-ui/src/pages/org/OIDCClientsPage.tsx
T

769 lines
33 KiB
TypeScript
Raw Normal View History

import { useState, useEffect, useRef } from "react";
2026-03-04 18:43:12 +05:45
import {
Plus, Key, MoreHorizontal, Copy, Trash2, Loader2,
AlertCircle, CheckCircle, Network, Terminal, Check,
2026-03-31 12:56:52 +05:45
Globe, RefreshCw, Info, Pencil,
2026-03-04 18:43:12 +05:45
} from "lucide-react";
2026-01-06 14:46:23 +00:00
import { Button } from "@/components/ui/button";
2026-03-04 18:43:12 +05:45
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
2026-01-06 14:46:23 +00:00
import { Badge } from "@/components/ui/badge";
2026-03-04 18:43:12 +05:45
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
2026-01-06 14:46:23 +00:00
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";
2026-03-04 18:43:12 +05:45
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";
2026-01-06 14:46:23 +00:00
2026-03-04 18:43:12 +05:45
// 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\/?$/, "");
2026-03-31 12:56:52 +05:45
/** 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");
2026-03-04 18:43:12 +05:45
}
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;
2026-03-31 12:56:52 +05:45
proxyUpstream?: string;
proxySetAuthHeader?: boolean;
proxySetXAuthHeader?: boolean;
proxyCookieSecret?: string;
2026-03-04 18:43:12 +05:45
isProxy: boolean;
}
2026-01-06 14:46:23 +00:00
export default function OIDCClientsPage() {
const { toast } = useToast();
const { selectedOrgId: orgId } = useOrg();
2026-03-04 18:43:12 +05:45
const { copy: copySecret, copied: secretCopied } = useCopyButton();
const { copy: copyConfig, copied: configCopied } = useCopyButton();
const [clients, setClients] = useState<OIDCClient[]>([]);
const [isLoading, setIsLoading] = useState(true);
2026-03-04 18:43:12 +05:45
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
const [isCreating, setIsCreating] = useState(false);
2026-03-04 18:43:12 +05:45
const [newSecret, setNewSecret] = useState<NewSecretState | null>(null);
2026-03-04 18:43:12 +05:45
// Generic form
const nameRef = useRef<HTMLInputElement>(null);
const urisRef = useRef<HTMLTextAreaElement>(null);
2026-03-04 18:43:12 +05:45
// Proxy form
const proxyNameRef = useRef<HTMLInputElement>(null);
const proxyHostRef = useRef<HTMLInputElement>(null);
2026-03-31 12:56:52 +05:45
const proxyUpstreamRef = useRef<HTMLInputElement>(null);
const [proxySetAuthHeader, setProxySetAuthHeader] = useState(true);
const [proxySetXAuthHeader, setProxySetXAuthHeader] = useState(true);
// Edit state
const [editingClient, setEditingClient] = useState<OIDCClient | null>(null);
const [editName, setEditName] = useState("");
const [editUris, setEditUris] = useState("");
const [isSavingEdit, setIsSavingEdit] = useState(false);
useEffect(() => {
if (!orgId) { setIsLoading(false); return; }
setIsLoading(true);
2026-03-04 18:43:12 +05:45
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 () => {
2026-03-04 18:43:12 +05:45
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;
2026-03-31 12:56:52 +05:45
// 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`];
2026-03-04 18:43:12 +05:45
}
setIsCreating(true);
try {
const result = await api.organizations.createClient(orgId, name, uris);
const created = result.client as OIDCClientWithSecret;
setClients((prev) => [...prev, created]);
2026-03-04 18:43:12 +05:45
setNewSecret({
clientId: created.client_id,
secret: created.client_secret,
proxyHost,
2026-03-31 12:56:52 +05:45
proxyUpstream: proxyUpstreamRef.current?.value.trim() || "http://127.0.0.1:8080/",
proxySetAuthHeader,
proxySetXAuthHeader,
proxyCookieSecret: dialogMode === "proxy" ? generateCookieSecret() : undefined,
2026-03-04 18:43:12 +05:45
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));
2026-03-04 18:43:12 +05:45
toast({ title: "Client deleted" });
} catch {
toast({ title: "Error", description: "Failed to delete client.", variant: "destructive" });
}
};
2026-03-31 12:56:52 +05:45
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);
}
};
2026-03-04 18:43:12 +05:45
const proxyConfig = newSecret?.isProxy && newSecret.proxyHost
2026-03-31 12:56:52 +05:45
? buildProxyConfig(
newSecret.clientId,
newSecret.secret,
newSecret.proxyHost,
newSecret.proxyUpstream ?? "http://127.0.0.1:8080/",
newSecret.proxySetAuthHeader ?? true,
newSecret.proxySetXAuthHeader ?? true,
newSecret.proxyCookieSecret ?? generateCookieSecret(),
)
2026-03-04 18:43:12 +05:45
: null;
2026-01-06 14:46:23 +00:00
return (
<div className="page-container">
2026-03-04 18:43:12 +05:45
{/* Header */}
2026-01-06 14:46:23 +00:00
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="page-title">OIDC Clients</h1>
2026-03-06 00:22:57 +05:45
<p className="page-description">Applications that authenticate via Secuird</p>
2026-01-06 14:46:23 +00:00
</div>
2026-03-04 18:43:12 +05:45
<Button onClick={() => setDialogMode("generic")}>
<Plus className="w-4 h-4 mr-2" />
Add client
</Button>
2026-01-06 14:46:23 +00:00
</div>
2026-03-04 18:43:12 +05:45
{/* One-time secret banner */}
{newSecret && (
2026-03-04 18:43:12 +05:45
<Card className="mb-6 border-green-500/40 bg-green-500/5">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0 space-y-3">
<div>
<p className="font-medium">Client created save your secret now</p>
<p className="text-sm text-muted-foreground">This will not be shown again.</p>
</div>
{/* Secret row */}
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Client secret</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-muted px-3 py-2 rounded font-mono break-all">
{newSecret.secret}
</code>
<Button variant="outline" size="sm" onClick={() => copySecret(newSecret.secret)}>
{secretCopied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</Button>
</div>
</div>
{/* oauth2-proxy config snippet */}
{proxyConfig && (
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
<Terminal className="w-3 h-3" />
oauth2-proxy config
</p>
<div className="relative">
<pre className="text-xs bg-muted px-3 py-2 rounded font-mono overflow-x-auto whitespace-pre">
{proxyConfig}
</pre>
<Button
variant="outline"
size="sm"
className="absolute top-2 right-2"
onClick={() => copyConfig(proxyConfig)}
>
{configCopied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</Button>
</div>
</div>
)}
</div>
2026-03-04 18:43:12 +05:45
<Button variant="ghost" size="icon" className="w-7 h-7 flex-shrink-0" onClick={() => setNewSecret(null)}>
×
</Button>
</div>
</CardContent>
</Card>
)}
2026-03-04 18:43:12 +05:45
{/* Client list */}
{isLoading ? (
2026-03-04 18:43:12 +05:45
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : clients.length === 0 ? (
<Card>
2026-03-04 18:43:12 +05:45
<CardContent className="py-16 flex flex-col items-center gap-4 text-center">
<Network className="w-10 h-10 text-muted-foreground/40" />
<div>
<p className="font-medium text-muted-foreground">No OIDC clients yet</p>
2026-03-06 00:22:57 +05:45
<p className="text-sm text-muted-foreground/70">Register an app to let it authenticate via Secuird</p>
2026-03-04 18:43:12 +05:45
</div>
<div className="flex gap-2 flex-wrap justify-center">
<Button variant="outline" onClick={() => setDialogMode("generic")}>
<Plus className="w-4 h-4 mr-2" />
Generic app
</Button>
<Button variant="outline" onClick={() => setDialogMode("proxy")}>
<Terminal className="w-4 h-4 mr-2" />
oauth2-proxy
</Button>
</div>
</CardContent>
</Card>
) : (
2026-03-04 18:43:12 +05:45
<div className="space-y-3">
{clients.map((client) => (
<Card key={client.id}>
2026-03-04 18:43:12 +05:45
<CardContent className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Key className="w-4 h-4 text-primary" />
2026-01-06 14:46:23 +00:00
</div>
2026-03-04 18:43:12 +05:45
<div className="min-w-0">
<p className="font-semibold truncate">{client.name}</p>
<div className="flex items-center gap-1.5 mt-1">
<code className="text-xs bg-muted px-2 py-0.5 rounded font-mono truncate max-w-[260px]">
{client.client_id}
</code>
2026-03-04 18:43:12 +05:45
<Button
variant="ghost"
size="icon"
className="w-5 h-5 flex-shrink-0"
onClick={() => navigator.clipboard.writeText(client.client_id).then(() =>
toast({ title: "Copied client ID" })
)}
>
<Copy className="w-3 h-3" />
</Button>
</div>
2026-03-04 18:43:12 +05:45
<div className="flex flex-wrap gap-1 mt-2">
{(client.scopes ?? []).map((scope) => (
<Badge key={scope} variant="secondary" className="text-xs">
{scope}
</Badge>
))}
</div>
2026-01-06 14:46:23 +00:00
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
2026-03-04 18:43:12 +05:45
<Button variant="ghost" size="icon" className="flex-shrink-0">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
2026-03-31 12:56:52 +05:45
<DropdownMenuItem onClick={() => openEditDialog(client)}>
<Pencil className="w-4 h-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => handleDelete(client.id)}
>
<Trash2 className="w-4 h-4 mr-2" />
2026-03-04 18:43:12 +05:45
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
2026-01-06 14:46:23 +00:00
</div>
2026-03-04 18:43:12 +05:45
<div className="mt-3 pt-3 border-t flex items-center justify-between text-xs text-muted-foreground">
<span>Created {new Date(client.created_at).toLocaleDateString()}</span>
<span>
{(client.redirect_uris ?? []).length} redirect URI{(client.redirect_uris ?? []).length !== 1 ? "s" : ""}
2026-03-04 18:43:12 +05:45
</span>
2026-01-06 14:46:23 +00:00
</div>
</CardContent>
</Card>
))}
</div>
)}
2026-03-04 18:43:12 +05:45
{/* Create dialog */}
<Dialog open={dialogMode !== null} onOpenChange={(open) => { if (!open) setDialogMode(null); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Add OIDC Client</DialogTitle>
2026-03-06 00:22:57 +05:45
<DialogDescription>Register an application to authenticate via Secuird</DialogDescription>
2026-03-04 18:43:12 +05:45
</DialogHeader>
<Tabs
value={dialogMode ?? "generic"}
onValueChange={(v) => setDialogMode(v as DialogMode)}
className="mt-2"
>
<TabsList className="w-full">
<TabsTrigger value="generic" className="flex-1">Generic app</TabsTrigger>
<TabsTrigger value="proxy" className="flex-1 flex items-center gap-1.5">
<Terminal className="w-3 h-3" />
oauth2-proxy
</TabsTrigger>
</TabsList>
{/* Generic tab */}
<TabsContent value="generic" className="space-y-4 pt-4">
<div className="space-y-2">
<Label htmlFor="genericName">Client name</Label>
<Input id="genericName" placeholder="My Application" ref={nameRef} />
</div>
<div className="space-y-2">
<Label htmlFor="redirectUris">Redirect URIs</Label>
<Textarea
id="redirectUris"
placeholder={"https://myapp.example.com/callback\nhttps://myapp.example.com/auth/callback"}
className="min-h-[80px] font-mono text-sm"
ref={urisRef}
/>
<p className="text-xs text-muted-foreground">One URI per line</p>
</div>
</TabsContent>
{/* oauth2-proxy tab */}
<TabsContent value="proxy" className="space-y-4 pt-4">
<div className="space-y-2">
<Label htmlFor="proxyName">Client name</Label>
<Input id="proxyName" placeholder="My Protected App" ref={proxyNameRef} />
</div>
<div className="space-y-2">
2026-03-31 12:56:52 +05:45
<Label htmlFor="proxyHost">Proxy public URL</Label>
<Input id="proxyHost" placeholder="https://app.example.com" ref={proxyHostRef} />
<p className="text-xs text-muted-foreground">
Full URL where oauth2-proxy is exposed.{" "}
<code className="bg-muted px-1 rounded">/oauth2/callback</code> will be appended as the redirect URI.
<br />
<span className="text-amber-500/80">Use <code className="bg-muted px-1 rounded">https://</code> in production — <code className="bg-muted px-1 rounded">cookie_secure</code> is set automatically.</span>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="proxyUpstream">Upstream (your app)</Label>
<Input
id="proxyUpstream"
placeholder="http://127.0.0.1:8080/"
ref={proxyUpstreamRef}
/>
2026-03-04 18:43:12 +05:45
<p className="text-xs text-muted-foreground">
2026-03-31 12:56:52 +05:45
The backend app oauth2-proxy forwards authenticated requests to.
2026-03-04 18:43:12 +05:45
</p>
</div>
2026-03-31 12:56:52 +05:45
<div className="space-y-2">
<Label className="text-sm">Headers forwarded to upstream</Label>
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
checked={proxySetAuthHeader}
onChange={(e) => setProxySetAuthHeader(e.target.checked)}
className="w-4 h-4 accent-primary rounded"
/>
<span className="text-sm">
<code className="bg-muted px-1 rounded text-xs">set_authorization_header</code>
<span className="text-muted-foreground ml-1.5 text-xs"> forwards <code className="bg-muted px-1 rounded">Authorization: Bearer </code></span>
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
checked={proxySetXAuthHeader}
onChange={(e) => setProxySetXAuthHeader(e.target.checked)}
className="w-4 h-4 accent-primary rounded"
/>
<span className="text-sm">
<code className="bg-muted px-1 rounded text-xs">set_x_auth_request_header</code>
<span className="text-muted-foreground ml-1.5 text-xs"> forwards <code className="bg-muted px-1 rounded">X-Auth-Request-User</code> / <code className="bg-muted px-1 rounded">X-Auth-Request-Email</code></span>
</span>
</label>
</div>
2026-03-04 18:43:12 +05:45
</div>
2026-03-31 12:56:52 +05:45
2026-03-04 18:43:12 +05:45
</TabsContent>
</Tabs>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setDialogMode(null)} disabled={isCreating}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={isCreating}>
{isCreating ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" />Creating</>
) : (
"Create client"
)}
</Button>
</div>
</DialogContent>
</Dialog>
2026-03-31 12:56:52 +05:45
{/* Edit dialog */}
<Dialog open={editingClient !== null} onOpenChange={(open) => { if (!open) setEditingClient(null); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Edit OIDC Client</DialogTitle>
<DialogDescription>Update the client name and redirect URIs.</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor="editName">Client name</Label>
<Input
id="editName"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="My Application"
/>
</div>
<div className="space-y-2">
<Label htmlFor="editUris">Redirect URIs</Label>
<Textarea
id="editUris"
value={editUris}
onChange={(e) => setEditUris(e.target.value)}
placeholder={"https://myapp.example.com/callback\nhttps://myapp.example.com/auth/callback"}
className="min-h-[80px] font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">One URI per line</p>
</div>
{editingClient && (
<div className="rounded-md bg-muted/50 border px-3 py-2 space-y-1">
<p className="text-xs text-muted-foreground font-medium">Client ID (read-only)</p>
<code className="text-xs font-mono text-foreground">{editingClient.client_id}</code>
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setEditingClient(null)} disabled={isSavingEdit}>
Cancel
</Button>
<Button onClick={handleSaveEdit} disabled={isSavingEdit || !editName.trim()}>
{isSavingEdit ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" />Saving</>
) : (
"Save changes"
)}
</Button>
</div>
</DialogContent>
</Dialog>
2026-03-04 18:43:12 +05:45
{/* ── Reference ─────────────────────────────────────────── */}
<div className="mt-8">
<div className="flex items-center gap-2 mb-3 text-sm font-medium text-muted-foreground">
<Info className="w-4 h-4" />
Integration reference
</div>
<Accordion type="multiple" className="space-y-2">
{/* Endpoints */}
<AccordionItem value="endpoints" className="border rounded-lg px-4">
<AccordionTrigger className="text-sm font-medium hover:no-underline py-3">
<span className="flex items-center gap-2">
<Globe className="w-4 h-4 text-muted-foreground" />
OIDC endpoints
</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-2 text-xs font-mono">
{[
["Discovery", "GET", "/.well-known/openid-configuration"],
["Authorization", "GET", "/oidc/authorize"],
["Token", "POST", "/oidc/token"],
["UserInfo", "GET", "/oidc/userinfo"],
["JWKS", "GET", "/oidc/jwks"],
["Revocation", "POST", "/oidc/revoke"],
["Introspection", "POST", "/oidc/introspect"],
].map(([label, method, path]) => (
<div key={path} className="flex items-center gap-3">
<Badge
variant="outline"
className={`w-12 justify-center text-[10px] shrink-0 ${method === "POST" ? "border-orange-500/50 text-orange-500" : "border-blue-500/50 text-blue-500"}`}
>
{method}
</Badge>
<code className="text-muted-foreground">{ISSUER_URL}{path}</code>
</div>
))}
</div>
<p className="text-xs text-muted-foreground mt-3">
Issuer: <code className="bg-muted px-1 rounded">{ISSUER_URL}</code>
</p>
</AccordionContent>
</AccordionItem>
{/* Scopes & flows */}
<AccordionItem value="scopes" className="border rounded-lg px-4">
<AccordionTrigger className="text-sm font-medium hover:no-underline py-3">
<span className="flex items-center gap-2">
<Key className="w-4 h-4 text-muted-foreground" />
Scopes &amp; flows
</span>
</AccordionTrigger>
<AccordionContent className="pb-4 space-y-4">
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">Available scopes</p>
<div className="flex flex-wrap gap-2">
{[
["openid", "Required. Issues an ID token."],
["profile", "Includes name, given_name, family_name."],
["email", "Includes email and email_verified."],
].map(([scope, desc]) => (
<div key={scope} className="flex items-start gap-2">
<Badge variant="secondary" className="font-mono text-xs shrink-0">{scope}</Badge>
<span className="text-xs text-muted-foreground">{desc}</span>
</div>
))}
</div>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">Supported flows</p>
<div className="space-y-1 text-xs">
<div className="flex items-center gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0" />
<span><strong>Authorization Code + PKCE</strong> recommended for all clients</span>
</div>
<div className="flex items-center gap-2">
<RefreshCw className="w-3.5 h-3.5 text-blue-500 shrink-0" />
<span><strong>Refresh Token</strong> token rotation supported</span>
</div>
<div className="flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5 text-yellow-500 shrink-0" />
<span><strong>Authorization Code (no PKCE)</strong> deprecated, PKCE required for new clients</span>
</div>
</div>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">ID token claims</p>
<div className="flex flex-wrap gap-1.5">
{["sub", "name", "email", "email_verified", "given_name", "family_name"].map((c) => (
<code key={c} className="text-xs bg-muted px-1.5 py-0.5 rounded">{c}</code>
))}
</div>
</div>
</AccordionContent>
</AccordionItem>
{/* oauth2-proxy quick-reference */}
<AccordionItem value="proxy-ref" className="border rounded-lg px-4">
<AccordionTrigger className="text-sm font-medium hover:no-underline py-3">
<span className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-muted-foreground" />
oauth2-proxy setup
</span>
</AccordionTrigger>
<AccordionContent className="pb-4 space-y-4">
<p className="text-xs text-muted-foreground">
Use the <strong>oauth2-proxy</strong> tab when creating a client to get a pre-filled config. Or build it manually:
</p>
{/* Step 1 */}
<div className="space-y-1">
<p className="text-xs font-medium">1 Create a client (use the dialog above)</p>
<p className="text-xs text-muted-foreground">
2026-03-31 12:56:52 +05:45
Set the proxy public URL to the address where oauth2-proxy is exposed, e.g.{" "}
<code className="bg-muted px-1 rounded">https://app.example.com</code>. The redirect URI{" "}
<code className="bg-muted px-1 rounded">https://app.example.com/oauth2/callback</code> is registered automatically.
2026-03-04 18:43:12 +05:45
</p>
</div>
{/* Step 2 */}
<div className="space-y-1">
<p className="text-xs font-medium">2 Minimal config</p>
2026-03-31 12:56:52 +05:45
<pre className="text-xs bg-muted rounded p-3 font-mono overflow-x-auto whitespace-pre">{`provider = "oidc"
oidc_issuer_url = "${ISSUER_URL}"
client_id = "<your-client-id>"
client_secret = "<your-client-secret>"
redirect_url = "https://<proxy-host>/oauth2/callback"
scope = "openid profile email"
cookie_secret = "$(openssl rand -base64 32 | head -c 32)"
cookie_secure = true
upstream = "http://127.0.0.1:8080/"
2026-03-04 18:43:12 +05:45
set_authorization_header = true
set_x_auth_request_header = true`}</pre>
</div>
{/* Step 3 */}
<div className="space-y-1">
<p className="text-xs font-medium">3 Run it</p>
<pre className="text-xs bg-muted rounded p-3 font-mono overflow-x-auto">{`oauth2-proxy --config ./oauth2-proxy.cfg`}</pre>
</div>
{/* Useful headers */}
<div className="space-y-2">
<p className="text-xs font-medium">Headers forwarded to your upstream</p>
<div className="space-y-1 text-xs font-mono">
{[
["X-Auth-Request-User", "User's subject (sub claim)"],
["X-Auth-Request-Email", "User's email address"],
["Authorization", "Bearer <access_token> (if set_authorization_header = true)"],
].map(([header, desc]) => (
<div key={header} className="flex items-start gap-3">
<code className="text-muted-foreground shrink-0">{header}</code>
<span className="text-muted-foreground/70 font-sans">{desc}</span>
</div>
))}
</div>
</div>
{/* Docker Compose snippet */}
<div className="space-y-1">
<p className="text-xs font-medium">Docker Compose example</p>
<pre className="text-xs bg-muted rounded p-3 font-mono overflow-x-auto whitespace-pre">{`services:
oauth2-proxy:
image: oauth2-proxy/oauth2-proxy:latest
ports: ["4180:4180"]
environment:
OAUTH2_PROXY_PROVIDER: oidc
OAUTH2_PROXY_OIDC_ISSUER_URL: "${ISSUER_URL}"
OAUTH2_PROXY_CLIENT_ID: \${OIDC_CLIENT_ID}
OAUTH2_PROXY_CLIENT_SECRET: \${OIDC_CLIENT_SECRET}
OAUTH2_PROXY_COOKIE_SECRET: \${COOKIE_SECRET}
2026-03-31 12:56:52 +05:45
OAUTH2_PROXY_COOKIE_SECURE: "true"
2026-03-04 18:43:12 +05:45
OAUTH2_PROXY_UPSTREAM: http://app:8080/
2026-03-31 12:56:52 +05:45
OAUTH2_PROXY_REDIRECT_URL: https://<your-proxy-host>/oauth2/callback
OAUTH2_PROXY_SET_AUTHORIZATION_HEADER: "true"
OAUTH2_PROXY_SET_XAUTHREQUEST: "true"`}</pre>
2026-03-04 18:43:12 +05:45
</div>
{/* Kubernetes snippet */}
<div className="space-y-1">
<p className="text-xs font-medium">Kubernetes Ingress annotations</p>
<pre className="text-xs bg-muted rounded p-3 font-mono overflow-x-auto whitespace-pre">{`nginx.ingress.kubernetes.io/auth-url: https://\$host/oauth2/auth
nginx.ingress.kubernetes.io/auth-signin: https://\$host/oauth2/sign_in
nginx.ingress.kubernetes.io/configuration-snippet: |
auth_request_set $user \$upstream_http_x_auth_request_user;
auth_request_set $email \$upstream_http_x_auth_request_email;
proxy_set_header X-User \$user;
proxy_set_header X-Email \$email;`}</pre>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
{/* ── /Reference ──────────────────────────────────────────── */}
2026-01-06 14:46:23 +00:00
</div>
);
}