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
@@ -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"
@@ -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 && (
+31 -26
View File
@@ -25,7 +25,7 @@ export const { handler, api } = betterAuth({
schema: schema,
}),
disabledPaths: [
"/sso/register",
// "/sso/register",
"/organization/create",
"/organization/update",
"/organization/delete",
@@ -44,30 +44,35 @@ export const { handler, api } = betterAuth({
logger: {
disabled: process.env.NODE_ENV === "production",
},
...(!IS_CLOUD && {
async trustedOrigins() {
const settings = await getWebServerSettings();
if (!settings) {
return [];
}
// ...(!IS_CLOUD && {
async trustedOrigins() {
const settings = await getWebServerSettings();
if (!settings) {
return [];
}
const providers = await getSSOProviders();
const domains = providers.map((provider) => provider.issuer);
return [
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
...(settings?.host ? [`https://${settings?.host}`] : []),
...domains.map((domain) => domain),
...(process.env.NODE_ENV === "development"
? [
"http://localhost:3000",
"https://absolutely-handy-falcon.ngrok-free.app",
"https://dev-pee8hhc3qbjlqedb.us.auth0.com",
"https://trial-2804699.okta.com",
]
: []),
];
},
}),
const providers = await getSSOProviders();
const issuerOrigins = providers.map((provider) => provider.issuer);
return [
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
...(settings?.host ? [`https://${settings?.host}`] : []),
...issuerOrigins,
...(process.env.NODE_ENV === "development"
? [
"http://localhost:3000",
"https://absolutely-handy-falcon.ngrok-free.app",
"https://dev-pee8hhc3qbjlqedb.us.auth0.com",
"https://trial-2804699.okta.com",
"https://login.microsoftonline.com",
"https://graph.microsoft.com",
]
: []),
];
},
// Untrusted OIDC discovery URL: The main discovery endpoint "https://login.microsoftonline.com/9f26c287-38e9-4731-9d1d-506365a6cc8e/.well-known/openid-configuration" is not trusted by your trusted origins configuration.
// }),
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
@@ -120,7 +125,7 @@ export const { handler, api } = betterAuth({
});
}
} else {
const isSSORequest = context?.path.includes("/sso/callback");
const isSSORequest = context?.path.includes("/sso");
if (isSSORequest) {
return;
}
@@ -136,7 +141,7 @@ export const { handler, api } = betterAuth({
}
},
after: async (user, context) => {
const isSSORequest = context?.path.includes("/sso/callback");
const isSSORequest = context?.path.includes("/sso");
const isAdminPresent = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"),
});
+1
View File
@@ -7637,6 +7637,7 @@ packages:
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
temporal-polyfill@0.2.5:
resolution: {integrity: sha512-ye47xp8Cb0nDguAhrrDS1JT1SzwEV9e26sSsrWzVu+yPZ7LzceEcH0i2gci9jWfOfSCCgM3Qv5nOYShVUUFUXA==}