feat(sso): update SAML registration dialog and settings for improved metadata handling

- Added support for IdP metadata XML in the SAML registration dialog, allowing users to paste full metadata for configuration.
- Updated the callback URL and audience handling to dynamically incorporate the base URL.
- Refactored the SSO settings to enable SAML provider registration and improved the display of callback URLs based on provider details.
- Enhanced trusted origins configuration in the authentication logic to include additional domains for development and production environments.
This commit is contained in:
Mauricio Siu
2026-02-01 19:50:33 -06:00
parent 11082f25d7
commit aa558b3a8c
4 changed files with 84 additions and 65 deletions

View File

@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { type FieldArrayPath, useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -52,12 +52,7 @@ const samlProviderSchema = z.object({
.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(),
idpMetadataXml: z.string().optional(),
});
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
@@ -72,8 +67,7 @@ const formDefaultValues: SamlProviderForm = {
domains: [""],
entryPoint: "",
cert: "",
callbackUrl: "",
audience: "",
idpMetadataXml: "",
};
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
@@ -81,6 +75,14 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
const [open, setOpen] = useState(false);
const { mutateAsync, isLoading } = api.sso.register.useMutation();
const [baseURL, setBaseURL] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
setBaseURL(window.location.origin);
}
}, []);
const form = useForm<SamlProviderForm>({
resolver: zodResolver(samlProviderSchema),
defaultValues: formDefaultValues,
@@ -95,6 +97,17 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
const onSubmit = async (data: SamlProviderForm) => {
try {
// maybe add the /saml/metadata endpoint to the baseURL
const baseURLWithMetadata = `${baseURL}/saml/metadata`;
const generateSpMetadata = (providerId: string) => {
return `<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${baseURL}">
<md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${baseURL}/api/auth/sso/saml2/callback/${providerId}" index="1"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>`;
};
await mutateAsync({
providerId: data.providerId,
issuer: data.issuer,
@@ -102,13 +115,20 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
samlConfig: {
entryPoint: data.entryPoint,
cert: data.cert,
callbackUrl: data.callbackUrl,
audience: data.audience,
wantAssertionsSigned: true,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
callbackUrl: `${baseURL}/api/auth/sso/saml2/callback/${data.providerId}`,
audience: baseURL,
idpMetadata: data.idpMetadataXml?.trim()
? { metadata: data.idpMetadataXml.trim() }
: undefined,
spMetadata: {
entityID: data.audience,
metadata: generateSpMetadata(data.providerId),
},
mapping: {
id: "nameID",
email: "email",
name: "displayName",
firstName: "givenName",
lastName: "surname",
},
},
});
@@ -264,39 +284,29 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
</FormItem>
)}
/>
<FormField
control={form.control}
name="callbackUrl"
name="idpMetadataXml"
render={({ field }) => (
<FormItem>
<FormLabel>Callback URL (ACS)</FormLabel>
<FormLabel>IdP metadata XML (optional)</FormLabel>
<FormControl>
<Input
placeholder="https://yourapp.com/api/auth/sso/saml2/callback/my-provider"
<Textarea
placeholder="Paste full IdP metadata XML if you have it (EntityDescriptor). Otherwise leave empty and use Issuer, IdP SSO URL and certificate above."
rows={5}
className="font-mono text-xs"
{...field}
/>
</FormControl>
<FormDescription>
Use the callback URL shown in your IdP app config for this
provider.
Some IdPs require full metadata; paste the XML here to
override issuer/entry point/cert.
</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"

View File

@@ -109,12 +109,12 @@ export const SSOSettings = () => {
Add OIDC provider
</Button>
</RegisterOidcDialog>
{/* <RegisterSamlDialog>
<RegisterSamlDialog>
<Button variant="secondary" size="sm">
<LogIn className="mr-2 size-4" />
Add SAML provider
</Button>
</RegisterSamlDialog> */}
</RegisterSamlDialog>
</div>
)}
@@ -234,12 +234,12 @@ export const SSOSettings = () => {
Add OIDC provider
</Button>
</RegisterOidcDialog>
{/* <RegisterSamlDialog>
<RegisterSamlDialog>
<Button variant="outline">
<LogIn className="mr-2 size-4" />
Add SAML provider
</Button>
</RegisterSamlDialog> */}
</RegisterSamlDialog>
</div>
</div>
)}
@@ -340,7 +340,10 @@ export const SSOSettings = () => {
Callback URL (configure in your IdP)
</span>
<p className="break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
{baseURL || "{baseURL}"}/api/auth/sso/callback/
{baseURL || "{baseURL}"}
{detailsProvider.samlConfig
? "/api/auth/sso/saml2/callback/"
: "/api/auth/sso/callback/"}
{detailsProvider.providerId}
</p>
{!baseURL && (