Implement SAML Provider Registration and Enhance OIDC Dialog: Add a new SAML provider registration dialog with form validation using Zod, integrate it into the SSO settings page, and refactor the OIDC registration dialog to utilize React Hook Form for improved state management and validation.

This commit is contained in:
Mauricio Siu
2026-01-29 22:11:09 -06:00
parent 7f27601f7f
commit 6064b8ca48
3 changed files with 468 additions and 254 deletions

View File

@@ -1,8 +1,11 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import {
@@ -14,55 +17,67 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const DEFAULT_SCOPES = ["openid", "email", "profile"];
const oidcProviderSchema = z.object({
providerId: z.string().min(1, "Provider ID is required").trim(),
issuer: z.string().min(1, "Issuer URL is required").url("Invalid URL").trim(),
domain: z.string().min(1, "Domain is required").trim(),
clientId: z.string().min(1, "Client ID is required").trim(),
clientSecret: z.string().min(1, "Client secret is required"),
scopes: z.string().optional(),
});
type OidcProviderForm = z.infer<typeof oidcProviderSchema>;
interface RegisterOidcDialogProps {
children: React.ReactNode;
onSuccess?: () => void;
}
const formDefaultValues: OidcProviderForm = {
providerId: "",
issuer: "",
domain: "",
clientId: "",
clientSecret: "",
scopes: DEFAULT_SCOPES.join(" "),
};
export function RegisterOidcDialog({ children, onSuccess }: RegisterOidcDialogProps) {
const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [form, setForm] = useState({
providerId: "",
issuer: "",
domain: "",
clientId: "",
clientSecret: "",
scopes: DEFAULT_SCOPES.join(" "),
const form = useForm<OidcProviderForm>({
resolver: zodResolver(oidcProviderSchema),
defaultValues: formDefaultValues,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (
!form.providerId.trim() ||
!form.issuer.trim() ||
!form.domain.trim() ||
!form.clientId.trim() ||
!form.clientSecret.trim()
) {
toast.error("Please fill in all required fields");
return;
}
const isSubmitting = form.formState.isSubmitting;
setIsSubmitting(true);
const onSubmit = async (data: OidcProviderForm) => {
try {
const scopes = form.scopes
.trim()
.split(/\s+/)
.filter(Boolean);
const { data, error } = await authClient.sso.register({
providerId: form.providerId.trim(),
issuer: form.issuer.trim(),
domain: form.domain.trim(),
const scopes = data.scopes?.trim()
? data.scopes.trim().split(/\s+/).filter(Boolean)
: DEFAULT_SCOPES;
const { error } = await authClient.sso.register({
providerId: data.providerId,
issuer: data.issuer,
domain: data.domain,
oidcConfig: {
clientId: form.clientId.trim(),
clientSecret: form.clientSecret.trim(),
scopes: scopes.length > 0 ? scopes : DEFAULT_SCOPES,
clientId: data.clientId,
clientSecret: data.clientSecret,
scopes,
pkce: true,
},
});
@@ -73,22 +88,13 @@ export function RegisterOidcDialog({ children, onSuccess }: RegisterOidcDialogPr
}
toast.success("OIDC provider registered successfully");
setForm({
providerId: "",
issuer: "",
domain: "",
clientId: "",
clientSecret: "",
scopes: DEFAULT_SCOPES.join(" "),
});
form.reset(formDefaultValues);
setOpen(false);
onSuccess?.();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to register SSO provider",
);
} finally {
setIsSubmitting(false);
}
};
@@ -99,107 +105,136 @@ export function RegisterOidcDialog({ children, onSuccess }: RegisterOidcDialogPr
<DialogHeader>
<DialogTitle>Register OIDC provider</DialogTitle>
<DialogDescription>
Add an OpenID Connect (OIDC) identity provider. Discovery will
fill endpoints from the issuer URL when possible.
Add any OIDC-compliant identity provider (e.g. Okta, Azure AD,
Google Workspace, Auth0, Keycloak). Discovery will fill endpoints
from the issuer URL when possible.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="providerId">Provider ID</Label>
<Input
id="providerId"
placeholder="e.g. okta or my-idp"
value={form.providerId}
onChange={(e) =>
setForm((f) => ({ ...f, providerId: e.target.value }))
}
/>
<p className="text-xs text-muted-foreground">
Unique identifier; used in callback URL path.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="issuer">Issuer URL</Label>
<Input
id="issuer"
placeholder="https://idp.example.com"
value={form.issuer}
onChange={(e) =>
setForm((f) => ({ ...f, issuer: e.target.value }))
}
/>
<p className="text-xs text-muted-foreground">
Discovery document is fetched from{" "}
<code className="rounded bg-muted px-1">
{"{issuer}"}/.well-known/openid-configuration
</code>
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="domain">Domain</Label>
<Input
id="domain"
placeholder="example.com"
value={form.domain}
onChange={(e) =>
setForm((f) => ({ ...f, domain: e.target.value }))
}
/>
<p className="text-xs text-muted-foreground">
Email domain(s) that use this provider (e.g. for sign-in by email).
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="clientId">Client ID</Label>
<Input
id="clientId"
placeholder="Client ID from IdP"
value={form.clientId}
onChange={(e) =>
setForm((f) => ({ ...f, clientId: e.target.value }))
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="clientSecret">Client secret</Label>
<Input
id="clientSecret"
type="password"
placeholder="Client secret from IdP"
value={form.clientSecret}
onChange={(e) =>
setForm((f) => ({ ...f, clientSecret: e.target.value }))
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="scopes">Scopes (optional)</Label>
<Input
id="scopes"
placeholder="openid email profile"
value={form.scopes}
onChange={(e) =>
setForm((f) => ({ ...f, scopes: e.target.value }))
}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && (
<Loader2 className="mr-2 size-4 animate-spin" />
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="providerId"
render={({ field }) => (
<FormItem>
<FormLabel>Provider ID</FormLabel>
<FormControl>
<Input
placeholder="e.g. okta or my-idp"
{...field}
/>
</FormControl>
<FormDescription>
Unique identifier; used in callback URL path.
</FormDescription>
<FormMessage />
</FormItem>
)}
Register provider
</Button>
</DialogFooter>
</form>
/>
<FormField
control={form.control}
name="issuer"
render={({ field }) => (
<FormItem>
<FormLabel>Issuer URL</FormLabel>
<FormControl>
<Input
placeholder="https://idp.example.com"
{...field}
/>
</FormControl>
<FormDescription>
Discovery document is fetched from{" "}
<code className="rounded bg-muted px-1">
{"{issuer}"}/.well-known/openid-configuration
</code>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input placeholder="example.com" {...field} />
</FormControl>
<FormDescription>
Email domain(s) that use this provider (e.g. for sign-in by
email).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>Client ID</FormLabel>
<FormControl>
<Input placeholder="Client ID from IdP" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Client secret</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Client secret from IdP"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scopes"
render={({ field }) => (
<FormItem>
<FormLabel>Scopes (optional)</FormLabel>
<FormControl>
<Input
placeholder="openid email profile"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
Register provider
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);

View File

@@ -0,0 +1,262 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
const samlProviderSchema = z.object({
providerId: z.string().min(1, "Provider ID is required").trim(),
issuer: z.string().min(1, "Issuer URL is required").url("Invalid URL").trim(),
domain: z.string().min(1, "Domain is required").trim(),
entryPoint: z
.string()
.min(1, "IdP SSO URL is required")
.url("Invalid URL")
.trim(),
cert: z.string().min(1, "IdP signing certificate is required"),
callbackUrl: z
.string()
.min(1, "Callback URL is required")
.url("Invalid URL")
.trim(),
audience: z.string().min(1, "Audience (Entity ID) is required").trim(),
});
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
interface RegisterSamlDialogProps {
children: React.ReactNode;
onSuccess?: () => void;
}
const formDefaultValues: SamlProviderForm = {
providerId: "",
issuer: "",
domain: "",
entryPoint: "",
cert: "",
callbackUrl: "",
audience: "",
};
export function RegisterSamlDialog({ children, onSuccess }: RegisterSamlDialogProps) {
const [open, setOpen] = useState(false);
const form = useForm<SamlProviderForm>({
resolver: zodResolver(samlProviderSchema),
defaultValues: formDefaultValues,
});
const isSubmitting = form.formState.isSubmitting;
const onSubmit = async (data: SamlProviderForm) => {
try {
const { error } = await authClient.sso.register({
providerId: data.providerId,
issuer: data.issuer,
domain: data.domain,
samlConfig: {
entryPoint: data.entryPoint,
cert: data.cert,
callbackUrl: data.callbackUrl,
audience: data.audience,
wantAssertionsSigned: true,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
},
});
if (error) {
toast.error(error.message ?? "Failed to register SAML provider");
return;
}
toast.success("SAML provider registered successfully");
form.reset(formDefaultValues);
setOpen(false);
onSuccess?.();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to register SAML provider",
);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Register SAML provider</DialogTitle>
<DialogDescription>
Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML,
OneLogin). You need the IdP&apos;s SSO URL and signing certificate.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="providerId"
render={({ field }) => (
<FormItem>
<FormLabel>Provider ID</FormLabel>
<FormControl>
<Input
placeholder="e.g. okta-saml or azure-saml"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="issuer"
render={({ field }) => (
<FormItem>
<FormLabel>Issuer URL</FormLabel>
<FormControl>
<Input
placeholder="https://idp.example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input placeholder="example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="entryPoint"
render={({ field }) => (
<FormItem>
<FormLabel>IdP SSO URL (Entry point)</FormLabel>
<FormControl>
<Input
placeholder="https://idp.example.com/sso"
{...field}
/>
</FormControl>
<FormDescription>
Single Sign-On URL from your IdP&apos;s SAML setup.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cert"
render={({ field }) => (
<FormItem>
<FormLabel>IdP signing certificate (X.509)</FormLabel>
<FormControl>
<Textarea
placeholder="Paste IdP signing certificate (PEM, BEGIN CERTIFICATE / END CERTIFICATE)"
rows={4}
className="font-mono text-xs"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="callbackUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Callback URL (ACS)</FormLabel>
<FormControl>
<Input
placeholder="https://yourapp.com/api/auth/sso/saml2/callback/my-provider"
{...field}
/>
</FormControl>
<FormDescription>
Use the callback URL shown in your IdP app config for this
provider.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="audience"
render={({ field }) => (
<FormItem>
<FormLabel>Audience (Entity ID)</FormLabel>
<FormControl>
<Input
placeholder="https://yourapp.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
Register provider
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,7 +1,6 @@
"use client";
import { Loader2, LogIn, ShieldCheck, Trash2 } from "lucide-react";
import { useState } from "react";
import { Loader2, LogIn, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
@@ -13,9 +12,9 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { RegisterOidcDialog } from "./register-oidc-dialog";
import { RegisterSamlDialog } from "./register-saml-dialog";
export function SSOSettings() {
const utils = api.useUtils();
@@ -23,57 +22,6 @@ export function SSOSettings() {
const { mutateAsync: deleteProvider, isLoading: isDeleting } =
api.sso.deleteProvider.useMutation();
const [verifyingId, setVerifyingId] = useState<string | null>(null);
const [requestingVerificationId, setRequestingVerificationId] = useState<
string | null
>(null);
const handleVerifyDomain = async (providerId: string) => {
setVerifyingId(providerId);
try {
const { data, error } = await authClient.sso.verifyDomain({
providerId,
});
if (error) {
toast.error(error.message ?? "Domain verification failed");
return;
}
toast.success("Domain verified successfully");
await utils.sso.listProviders.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Domain verification failed",
);
} finally {
setVerifyingId(null);
}
};
const handleRequestDomainVerification = async (providerId: string) => {
setRequestingVerificationId(providerId);
try {
const { data, error } = await authClient.sso.requestDomainVerification({
providerId,
});
if (error) {
toast.error(error.message ?? "Failed to request domain verification");
return;
}
toast.success(
"Verification token created. Add the TXT DNS record and verify.",
);
await utils.sso.listProviders.invalidate();
} catch (err) {
toast.error(
err instanceof Error
? err.message
: "Failed to request domain verification",
);
} finally {
setRequestingVerificationId(null);
}
};
return (
<div className="flex flex-col gap-4 rounded-lg border p-4">
<div className="flex flex-col gap-2">
@@ -106,9 +54,14 @@ export function SSOSettings() {
Add OIDC provider
</Button>
</RegisterOidcDialog>
<span className="text-xs text-muted-foreground">
SAML support can be added via API or future UI.
</span>
<RegisterSamlDialog
onSuccess={() => utils.sso.listProviders.invalidate()}
>
<Button variant="secondary" size="sm">
<LogIn className="mr-2 size-4" />
Add SAML provider
</Button>
</RegisterSamlDialog>
</div>
)}
@@ -119,7 +72,6 @@ export function SSOSettings() {
{providers.map((provider) => {
const isOidc = !!provider.oidcConfig;
const isSaml = !!provider.samlConfig;
const verified = !!provider.domainVerified;
return (
<Card key={provider.id} className="overflow-hidden">
@@ -146,56 +98,11 @@ export function SSOSettings() {
SAML
</Badge>
)}
{verified && (
<Badge
variant="default"
className="text-xs bg-green-600"
>
Verified
</Badge>
)}
</div>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-wrap gap-2 pt-0">
{!verified && (
<>
<Button
variant="outline"
size="sm"
disabled={verifyingId === provider.providerId}
onClick={() =>
handleVerifyDomain(provider.providerId)
}
>
{verifyingId === provider.providerId ? (
<Loader2 className="mr-1 size-3 animate-spin" />
) : (
<ShieldCheck className="mr-1 size-3" />
)}
Verify domain
</Button>
<Button
variant="ghost"
size="sm"
disabled={
requestingVerificationId === provider.providerId
}
onClick={() =>
handleRequestDomainVerification(
provider.providerId,
)
}
>
{requestingVerificationId ===
provider.providerId ? (
<Loader2 className="mr-1 size-3 animate-spin" />
) : null}
New verification token
</Button>
</>
)}
<DialogAction
title="Remove SSO provider"
description={`Remove provider "${provider.providerId}"? Users will no longer be able to sign in with this IdP.`}
@@ -241,19 +148,29 @@ export function SSOSettings() {
<div className="space-y-1">
<h3 className="text-lg font-semibold">No SSO providers</h3>
<p className="text-sm text-muted-foreground">
Add an OIDC provider to allow users to sign in with their
organization&apos;s identity provider (e.g. Okta, Azure AD).
Add an OIDC or SAML provider so users can sign in with their
organization&apos;s IdP (e.g. Okta, Azure AD).
</p>
</div>
</div>
<RegisterOidcDialog
onSuccess={() => utils.sso.listProviders.invalidate()}
>
<Button variant="secondary">
<LogIn className="mr-2 size-4" />
Add OIDC provider
</Button>
</RegisterOidcDialog>
<div className="flex flex-wrap gap-2 justify-center">
<RegisterOidcDialog
onSuccess={() => utils.sso.listProviders.invalidate()}
>
<Button variant="secondary">
<LogIn className="mr-2 size-4" />
Add OIDC provider
</Button>
</RegisterOidcDialog>
<RegisterSamlDialog
onSuccess={() => utils.sso.listProviders.invalidate()}
>
<Button variant="outline">
<LogIn className="mr-2 size-4" />
Add SAML provider
</Button>
</RegisterSamlDialog>
</div>
</div>
)}
</>