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 { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react"; 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 { type FieldArrayPath, useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
@@ -52,12 +52,7 @@ const samlProviderSchema = z.object({
.url("Invalid URL") .url("Invalid URL")
.trim(), .trim(),
cert: z.string().min(1, "IdP signing certificate is required"), cert: z.string().min(1, "IdP signing certificate is required"),
callbackUrl: z idpMetadataXml: z.string().optional(),
.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>; type SamlProviderForm = z.infer<typeof samlProviderSchema>;
@@ -72,8 +67,7 @@ const formDefaultValues: SamlProviderForm = {
domains: [""], domains: [""],
entryPoint: "", entryPoint: "",
cert: "", cert: "",
callbackUrl: "", idpMetadataXml: "",
audience: "",
}; };
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) { export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
@@ -81,6 +75,14 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { mutateAsync, isLoading } = api.sso.register.useMutation(); const { mutateAsync, isLoading } = api.sso.register.useMutation();
const [baseURL, setBaseURL] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
setBaseURL(window.location.origin);
}
}, []);
const form = useForm<SamlProviderForm>({ const form = useForm<SamlProviderForm>({
resolver: zodResolver(samlProviderSchema), resolver: zodResolver(samlProviderSchema),
defaultValues: formDefaultValues, defaultValues: formDefaultValues,
@@ -95,6 +97,17 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
const onSubmit = async (data: SamlProviderForm) => { const onSubmit = async (data: SamlProviderForm) => {
try { 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({ await mutateAsync({
providerId: data.providerId, providerId: data.providerId,
issuer: data.issuer, issuer: data.issuer,
@@ -102,13 +115,20 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
samlConfig: { samlConfig: {
entryPoint: data.entryPoint, entryPoint: data.entryPoint,
cert: data.cert, cert: data.cert,
callbackUrl: data.callbackUrl, callbackUrl: `${baseURL}/api/auth/sso/saml2/callback/${data.providerId}`,
audience: data.audience, audience: baseURL,
wantAssertionsSigned: true, idpMetadata: data.idpMetadataXml?.trim()
signatureAlgorithm: "sha256", ? { metadata: data.idpMetadataXml.trim() }
digestAlgorithm: "sha256", : undefined,
spMetadata: { 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> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="callbackUrl" name="idpMetadataXml"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Callback URL (ACS)</FormLabel> <FormLabel>IdP metadata XML (optional)</FormLabel>
<FormControl> <FormControl>
<Input <Textarea
placeholder="https://yourapp.com/api/auth/sso/saml2/callback/my-provider" 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} {...field}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Use the callback URL shown in your IdP app config for this Some IdPs require full metadata; paste the XML here to
provider. override issuer/entry point/cert.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </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> <DialogFooter>
<Button <Button
type="button" type="button"
@@ -109,12 +109,12 @@ export const SSOSettings = () => {
Add OIDC provider Add OIDC provider
</Button> </Button>
</RegisterOidcDialog> </RegisterOidcDialog>
{/* <RegisterSamlDialog> <RegisterSamlDialog>
<Button variant="secondary" size="sm"> <Button variant="secondary" size="sm">
<LogIn className="mr-2 size-4" /> <LogIn className="mr-2 size-4" />
Add SAML provider Add SAML provider
</Button> </Button>
</RegisterSamlDialog> */} </RegisterSamlDialog>
</div> </div>
)} )}
@@ -234,12 +234,12 @@ export const SSOSettings = () => {
Add OIDC provider Add OIDC provider
</Button> </Button>
</RegisterOidcDialog> </RegisterOidcDialog>
{/* <RegisterSamlDialog> <RegisterSamlDialog>
<Button variant="outline"> <Button variant="outline">
<LogIn className="mr-2 size-4" /> <LogIn className="mr-2 size-4" />
Add SAML provider Add SAML provider
</Button> </Button>
</RegisterSamlDialog> */} </RegisterSamlDialog>
</div> </div>
</div> </div>
)} )}
@@ -340,7 +340,10 @@ export const SSOSettings = () => {
Callback URL (configure in your IdP) Callback URL (configure in your IdP)
</span> </span>
<p className="break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs"> <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} {detailsProvider.providerId}
</p> </p>
{!baseURL && ( {!baseURL && (
+31 -26
View File
@@ -25,7 +25,7 @@ export const { handler, api } = betterAuth({
schema: schema, schema: schema,
}), }),
disabledPaths: [ disabledPaths: [
"/sso/register", // "/sso/register",
"/organization/create", "/organization/create",
"/organization/update", "/organization/update",
"/organization/delete", "/organization/delete",
@@ -44,30 +44,35 @@ export const { handler, api } = betterAuth({
logger: { logger: {
disabled: process.env.NODE_ENV === "production", disabled: process.env.NODE_ENV === "production",
}, },
...(!IS_CLOUD && { // ...(!IS_CLOUD && {
async trustedOrigins() { async trustedOrigins() {
const settings = await getWebServerSettings(); const settings = await getWebServerSettings();
if (!settings) { if (!settings) {
return []; return [];
} }
const providers = await getSSOProviders(); const providers = await getSSOProviders();
const domains = providers.map((provider) => provider.issuer); const issuerOrigins = providers.map((provider) => provider.issuer);
return [
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []), return [
...(settings?.host ? [`https://${settings?.host}`] : []), ...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
...domains.map((domain) => domain), ...(settings?.host ? [`https://${settings?.host}`] : []),
...(process.env.NODE_ENV === "development" ...issuerOrigins,
? [ ...(process.env.NODE_ENV === "development"
"http://localhost:3000", ? [
"https://absolutely-handy-falcon.ngrok-free.app", "http://localhost:3000",
"https://dev-pee8hhc3qbjlqedb.us.auth0.com", "https://absolutely-handy-falcon.ngrok-free.app",
"https://trial-2804699.okta.com", "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: { emailVerification: {
sendOnSignUp: true, sendOnSignUp: true,
autoSignInAfterVerification: true, autoSignInAfterVerification: true,
@@ -120,7 +125,7 @@ export const { handler, api } = betterAuth({
}); });
} }
} else { } else {
const isSSORequest = context?.path.includes("/sso/callback"); const isSSORequest = context?.path.includes("/sso");
if (isSSORequest) { if (isSSORequest) {
return; return;
} }
@@ -136,7 +141,7 @@ export const { handler, api } = betterAuth({
} }
}, },
after: async (user, context) => { after: async (user, context) => {
const isSSORequest = context?.path.includes("/sso/callback"); const isSSORequest = context?.path.includes("/sso");
const isAdminPresent = await db.query.member.findFirst({ const isAdminPresent = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"), where: eq(schema.member.role, "owner"),
}); });
+1
View File
@@ -7637,6 +7637,7 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'} 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 [email protected]
[email protected]: [email protected]:
resolution: {integrity: sha512-ye47xp8Cb0nDguAhrrDS1JT1SzwEV9e26sSsrWzVu+yPZ7LzceEcH0i2gci9jWfOfSCCgM3Qv5nOYShVUUFUXA==} resolution: {integrity: sha512-ye47xp8Cb0nDguAhrrDS1JT1SzwEV9e26sSsrWzVu+yPZ7LzceEcH0i2gci9jWfOfSCCgM3Qv5nOYShVUUFUXA==}