diff --git a/.changeset/sso-saml-model-split.md b/.changeset/sso-saml-model-split.md new file mode 100644 index 0000000000..d507d5fe1d --- /dev/null +++ b/.changeset/sso-saml-model-split.md @@ -0,0 +1,31 @@ +--- +"@better-auth/sso": minor +--- + +### Breaking: SAML configuration changes + +**`callbackUrl` removed from `samlConfig`.** +The ACS URL is now always derived from your `baseURL` and `providerId`. Remove `callbackUrl` from your SAML provider configuration. The post-login redirect destination is set per sign-in via `callbackURL` in `signIn.sso()`: + +```ts +await authClient.signIn.sso({ + providerId: "my-provider", + callbackURL: "/dashboard", +}); +``` + +**`/sso/saml2/callback/:providerId` endpoint removed.** +Update your IdP's ACS URL to `/sso/saml2/sp/acs/:providerId`. This endpoint handles both GET and POST requests. + +**`spMetadata` is now optional.** +You no longer need to pass `spMetadata: {}` when registering a provider. SP metadata is auto-generated from your configuration. + +**Removed unused fields from `SAMLConfig`:** +`decryptionPvk`, `additionalParams`, `idpMetadata.entityURL`, `idpMetadata.redirectURL`. These were stored but never read. Remove them from your configuration if present. + +### Bug fixes + +- Fix SLO SessionIndex matching: LogoutRequests with a SessionIndex were silently failing to delete the correct session. +- Audience validation now defaults to the SP entity ID when `audience` is not configured, per SAML Core section 2.5.1. +- Restore `AllowCreate` in AuthnRequests, required by IdPs that use JIT provisioning. +- SP metadata endpoint now reflects actual SP capabilities (encryption, signing, SLO). diff --git a/docs/content/docs/guides/saml-sso-with-okta.mdx b/docs/content/docs/guides/saml-sso-with-okta.mdx index 63e775d43c..86e4231dcc 100644 --- a/docs/content/docs/guides/saml-sso-with-okta.mdx +++ b/docs/content/docs/guides/saml-sso-with-okta.mdx @@ -26,14 +26,14 @@ In this setup: 5. Configure the following settings: - * **Single Sign-on URL**: Your Better Auth callback endpoint (e.g., `http://localhost:3000/api/auth/sso/saml2/callback/sso`). Note: `sso` is your `providerId` + * **Single Sign-on URL**: Your Better Auth callback endpoint (e.g., `http://localhost:3000/api/auth/sso/saml2/sp/acs/sso`). Note: `sso` is your `providerId` * **Audience URI (SP Entity ID)**: Your Better Auth metadata URL (e.g., `http://localhost:3000/api/auth/sso/saml2/sp/metadata`) * **Name ID format**: Email Address or any of your choice. 6. Download the IdP metadata XML file and certificate - **IdP-Initiated SSO**: If you want users to access your app from the Okta dashboard, make sure the **Single Sign-on URL** points to the callback endpoint (`/api/auth/sso/saml2/callback/{providerId}`). Better Auth automatically handles both SP-initiated and IdP-initiated flows. + **IdP-Initiated SSO**: If you want users to access your app from the Okta dashboard, make sure the **Single Sign-on URL** points to the callback endpoint (`/api/auth/sso/saml2/sp/acs/{providerId}`). Better Auth automatically handles both SP-initiated and IdP-initiated flows. ### Step 2: Configure Better Auth @@ -49,7 +49,6 @@ const ssoConfig = { // SP Configuration issuer: "http://localhost:3000/api/auth/sso/saml2/sp/metadata", entryPoint: "https://trial-1076874.okta.com/app/trial-1076874_samltest_1/exktofb0a62hqLAUL697/sso/saml", - callbackUrl: "http://localhost:3000/api/auth/sso/saml2/sp/acs/sso", // IdP Configuration idpMetadata: { entityID: "https://trial-1076874.okta.com/app/exktofb0a62hqLAUL697/sso/saml/metadata", @@ -156,7 +155,7 @@ await authClient.signIn.sso({ * Never use these certificates in production * The example uses `localhost:3000` - adjust URLs for your environment * For production, always use proper IdP providers like Okta, Azure AD, or OneLogin -* `callbackUrl` is the SAML ACS endpoint URL. If omitted, it defaults to `{baseURL}/sso/saml2/sp/acs/{providerId}`. The post-login destination is controlled by `callbackURL` in `signIn.sso()` +* The ACS URL is derived automatically from `{baseURL}/sso/saml2/sp/acs/{providerId}`. The post-login destination is controlled by `callbackURL` in `signIn.sso()` ### Step 5: Dynamically Registering SAML Providers diff --git a/docs/content/docs/plugins/sso.mdx b/docs/content/docs/plugins/sso.mdx index 4d04952d5b..5acc873219 100644 --- a/docs/content/docs/plugins/sso.mdx +++ b/docs/content/docs/plugins/sso.mdx @@ -413,7 +413,6 @@ To register a SAML provider, use the `registerSSOProvider` endpoint with SAML co samlConfig: { entryPoint: "https://idp.example.com/sso", cert: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", - callbackUrl: "https://yourapp.com/api/auth/sso/saml2/callback/saml-provider", audience: "https://yourapp.com", wantAssertionsSigned: true, signatureAlgorithm: "sha256", @@ -464,7 +463,6 @@ To register a SAML provider, use the `registerSSOProvider` endpoint with SAML co samlConfig: { entryPoint: "https://idp.example.com/sso", cert: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", - callbackUrl: "https://yourapp.com/api/auth/sso/saml2/callback/saml-provider", audience: "https://yourapp.com", wantAssertionsSigned: true, signatureAlgorithm: "sha256", @@ -515,7 +513,7 @@ For IdP-initiated flows (e.g., via Okta dashboard), your framework may require a Create this file to prevent 404 errors: - ```ts title="app/api/auth/sso/saml2/callback/[providerId]/route.ts" + ```ts title="app/api/auth/sso/saml2/sp/acs/[providerId]/route.ts" import { auth } from "@/lib/auth"; import { NextResponse } from "next/server"; @@ -539,7 +537,6 @@ For SAML providers, you can retrieve the Service Provider metadata XML that need const response = await auth.api.spMetadata({ query: { providerId: "saml-provider", - format: "xml" // or "json" } }); @@ -973,7 +970,6 @@ const auth = betterAuth({ issuer: "https://your-app.com", entryPoint: "https://idp.example.com/sso", cert: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", - callbackUrl: "http://localhost:3000/api/auth/sso/saml2/sp/acs", spMetadata: { entityID: "http://localhost:3000/api/auth/sso/saml2/sp/metadata", metadata: "", @@ -1121,10 +1117,7 @@ The SSO plugin includes assertion replay protection to prevent attackers from ca 3. If it's a new assertion, it's stored in the database until its `NotOnOrAfter` expiration 4. If it's a duplicate (replay attack), the request is rejected -**Both SAML endpoints are protected:** - -* `/sso/saml2/callback/:providerId` -* `/sso/saml2/sp/acs/:providerId` +The SAML ACS endpoint (`/sso/saml2/sp/acs/:providerId`) is protected against assertion replay. Replay protection uses the database verification table, so it works correctly in multi-instance deployments without additional configuration. @@ -1332,7 +1325,7 @@ The provider ID is stored in the OAuth state so the callback can identify which - This option only affects OIDC providers. SAML providers already support custom callback URLs via `callbackUrl` in `samlConfig`. + This option only affects OIDC providers. SAML providers use a separate ACS endpoint that is configured automatically. @@ -1471,22 +1464,22 @@ you can create a new verification token: The plugin automatically creates the following SAML endpoints: * **SP Metadata**: `/api/auth/sso/saml2/sp/metadata?providerId={providerId}` -* **SAML Callback**: `/api/auth/sso/saml2/callback/{providerId}` (supports both GET and POST) +* **SAML Callback**: `/api/auth/sso/saml2/sp/acs/{providerId}` (supports both GET and POST) ### SAML Callback URL Configuration -The SAML callback endpoint (`/api/auth/sso/saml2/callback/{providerId}`) handles both **SP-initiated** and **IdP-initiated** SSO flows: +The SAML callback endpoint (`/api/auth/sso/saml2/sp/acs/{providerId}`) handles both **SP-initiated** and **IdP-initiated** SSO flows: * **SP-initiated**: User clicks "Sign in with SSO" in your app → redirects to IdP → IdP POSTs SAMLResponse to callback * **IdP-initiated**: User clicks app icon in IdP dashboard (Okta, Azure AD, etc.) → IdP POSTs SAMLResponse to callback -**Important**: The `callbackUrl` in your SAML configuration should point to your application's destination URL (e.g., `/dashboard`), **not** the callback route itself. Better Auth automatically handles the callback route and redirects users to your specified `callbackUrl` after successful authentication. +**Important**: The SAML ACS URL is derived automatically from your `baseURL` and `providerId`. The post-login redirect is controlled by `callbackURL` in the `signIn.sso()` call: ```ts -samlConfig: { - callbackUrl: "/dashboard", // Correct - points to your app destination - // callbackUrl: "/api/auth/sso/saml2/callback/my-provider" // Incorrect - don't point to callback route -} +await authClient.signIn.sso({ + providerId: "my-provider", + callbackURL: "/dashboard", +}); ``` The callback route supports both GET and POST methods automatically, so you don't need to create any additional route handlers in your framework. @@ -1497,16 +1490,16 @@ The plugin requires additional fields in the `ssoProvider` table to store the pr @@ -1516,7 +1509,7 @@ The `ssoProvider` schema is extended as follows: @@ -1527,8 +1520,8 @@ Better Auth supports **IdP-initiated SSO flows**, where users access your applic **How it works:** 1. User clicks your app icon in the IdP dashboard -2. IdP POSTs SAMLResponse to `/api/auth/sso/saml2/callback/{providerId}` -3. Better Auth processes the assertion, creates a session, and redirects to your `callbackUrl` +2. IdP POSTs SAMLResponse to `/api/auth/sso/saml2/sp/acs/{providerId}` +3. Better Auth processes the assertion, creates a session, and redirects to your application 4. Browser follows the redirect with a GET request (handled automatically) **No additional configuration required** - the callback route automatically handles both GET and POST requests. @@ -1562,195 +1555,195 @@ If you want to allow account linking for specific trusted providers, enable the diff --git a/packages/sso/src/domain-verification.test.ts b/packages/sso/src/domain-verification.test.ts index 118a21c1df..53012831e5 100644 --- a/packages/sso/src/domain-verification.test.ts +++ b/packages/sso/src/domain-verification.test.ts @@ -131,7 +131,6 @@ describe("Domain verification", async () => { samlConfig: { entryPoint: "http://idp.com:", cert: "the-cert", - callbackUrl: "http://hello.com:8081/api/sso/saml2/callback", spMetadata: {}, }, organizationId, @@ -545,7 +544,6 @@ describe("Domain verification", async () => { samlConfig: { entryPoint: "http://idp.com:", cert: "the-cert", - callbackUrl: "http://hello.com:8081/api/sso/saml2/callback", spMetadata: {}, }, }, @@ -590,7 +588,6 @@ describe("Domain verification", async () => { samlConfig: { entryPoint: "http://idp.com:", cert: "the-cert", - callbackUrl: "http://hello.com:8081/api/sso/saml2/callback", spMetadata: {}, }, }, diff --git a/packages/sso/src/index.ts b/packages/sso/src/index.ts index c873805544..7249743958 100644 --- a/packages/sso/src/index.ts +++ b/packages/sso/src/index.ts @@ -17,7 +17,6 @@ import { import { acsEndpoint, callbackSSO, - callbackSSOSAML, callbackSSOShared, initiateSLO, registerSSOProvider, @@ -100,7 +99,6 @@ type SSOEndpoints = { signInSSO: ReturnType; callbackSSO: ReturnType; callbackSSOShared: ReturnType; - callbackSSOSAML: ReturnType; acsEndpoint: ReturnType; sloEndpoint: ReturnType; initiateSLO: ReturnType; @@ -125,9 +123,8 @@ export type SSOPlugin = { * which won't have a matching Origin header. */ const SAML_SKIP_ORIGIN_CHECK_PATHS = [ - "/sso/saml2/callback", // SP-initiated SSO callback (prefix matches /callback/:providerId) - "/sso/saml2/sp/acs", // IdP-initiated SSO ACS (prefix matches /sp/acs/:providerId) - "/sso/saml2/sp/slo", // IdP-initiated SLO (prefix matches /sp/slo/:providerId) + "/sso/saml2/sp/acs", // SAML ACS endpoint (prefix matches /sp/acs/:providerId) + "/sso/saml2/sp/slo", // SAML SLO endpoint (prefix matches /sp/slo/:providerId) ]; export function sso< @@ -163,7 +160,6 @@ export function sso( signInSSO: signInSSO(optionsWithStore), callbackSSO: callbackSSO(optionsWithStore), callbackSSOShared: callbackSSOShared(optionsWithStore), - callbackSSOSAML: callbackSSOSAML(optionsWithStore), acsEndpoint: acsEndpoint(optionsWithStore), sloEndpoint: sloEndpoint(optionsWithStore), initiateSLO: initiateSLO(optionsWithStore), diff --git a/packages/sso/src/providers.test.ts b/packages/sso/src/providers.test.ts index 14d9cb5772..f40a792215 100644 --- a/packages/sso/src/providers.test.ts +++ b/packages/sso/src/providers.test.ts @@ -130,7 +130,6 @@ describe("SSO provider read endpoints", () => { samlConfig: { entryPoint: "https://idp.example.com/sso", cert: TEST_CERT, - callbackUrl: "http://localhost:3000/api/sso/callback", audience: "my-audience", wantAssertionsSigned: true, spMetadata: {}, @@ -382,7 +381,6 @@ describe("SSO provider read endpoints", () => { samlConfig: JSON.stringify({ entryPoint: "https://idp.example.com/sso", cert: TEST_CERT, - callbackUrl: "http://localhost:3000/api/sso/callback", audience: "my-audience", wantAssertionsSigned: true, spMetadata: {}, @@ -425,7 +423,6 @@ describe("SSO provider read endpoints", () => { samlConfig: JSON.stringify({ entryPoint: "https://idp.example.com/sso", cert: TEST_CERT, - callbackUrl: "http://localhost:3000/api/sso/callback", audience: "my-audience", wantAssertionsSigned: true, spMetadata: {}, @@ -621,7 +618,6 @@ describe("SSO provider read endpoints", () => { samlConfig: JSON.stringify({ entryPoint: "https://idp.example.com/sso", cert: TEST_CERT, - callbackUrl: "http://localhost:3000/api/sso/callback", audience: "my-audience", wantAssertionsSigned: true, spMetadata: {}, @@ -662,7 +658,6 @@ describe("SSO provider read endpoints", () => { samlConfig: JSON.stringify({ entryPoint: "https://idp.example.com/sso", cert: TEST_CERT, - callbackUrl: "http://localhost:3000/api/sso/callback", audience: "my-audience", wantAssertionsSigned: true, spMetadata: {}, @@ -740,7 +735,6 @@ describe("SSO provider read endpoints", () => { samlConfig: JSON.stringify({ entryPoint: "https://idp.example.com/sso", cert: "invalid-cert-data", - callbackUrl: "http://localhost:3000/api/sso/callback", }), }); @@ -1084,7 +1078,6 @@ describe("SSO provider read endpoints", () => { samlConfig: { entryPoint: "https://idp.example.com/sso", cert: TEST_CERT, - callbackUrl: "http://localhost:3000/api/sso/callback", spMetadata: {}, }, }, diff --git a/packages/sso/src/routes/helpers.ts b/packages/sso/src/routes/helpers.ts index 7236512344..14a6d8d388 100644 --- a/packages/sso/src/routes/helpers.ts +++ b/packages/sso/src/routes/helpers.ts @@ -52,45 +52,54 @@ export function createSP( ) { const spData = config.spMetadata; const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`; - // TODO: derive ACS URL exclusively from baseURL + providerId. - // callbackUrl doubles as both ACS and post-auth redirect, which breaks - // when it points to an app destination (e.g., /dashboard). - const acsUrl = - config.callbackUrl || `${baseURL}/sso/saml2/sp/acs/${providerId}`; + const acsUrl = `${baseURL}/sso/saml2/sp/acs/${providerId}`; + + // When no SP metadata XML is provided, generate it so samlify can read + // authnRequestsSigned and other flags that only work via metadata. + let metadata = spData?.metadata; + if (!metadata) { + metadata = + saml + .SPMetadata({ + entityID: spData?.entityID || config.issuer, + assertionConsumerService: [ + { + Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", + Location: acsUrl, + }, + ], + singleLogoutService: opts?.sloOptions + ? [ + { + Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", + Location: sloLocation, + }, + { + Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", + Location: sloLocation, + }, + ] + : undefined, + wantMessageSigned: config.wantAssertionsSigned || false, + authnRequestsSigned: config.authnRequestsSigned || false, + nameIDFormat: config.identifierFormat + ? [config.identifierFormat] + : undefined, + }) + .getMetadata() || ""; + } return saml.ServiceProvider({ - entityID: spData?.entityID || config.issuer, - assertionConsumerService: spData?.metadata - ? undefined - : [ - { - Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", - Location: acsUrl, - }, - ], - singleLogoutService: [ - { - Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", - Location: sloLocation, - }, - { - Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", - Location: sloLocation, - }, - ], - wantMessageSigned: config.wantAssertionsSigned || false, + metadata, + allowCreate: true, wantLogoutRequestSigned: opts?.sloOptions?.wantLogoutRequestSigned ?? false, wantLogoutResponseSigned: opts?.sloOptions?.wantLogoutResponseSigned ?? false, - metadata: spData?.metadata, privateKey: spData?.privateKey || config.privateKey, privateKeyPass: spData?.privateKeyPass, isAssertionEncrypted: spData?.isAssertionEncrypted || false, encPrivateKey: spData?.encPrivateKey, encPrivateKeyPass: spData?.encPrivateKeyPass, - nameIDFormat: config.identifierFormat - ? [config.identifierFormat] - : undefined, relayState: opts?.relayState, }); } diff --git a/packages/sso/src/routes/providers.ts b/packages/sso/src/routes/providers.ts index 78aa5ab6ba..8129bec314 100644 --- a/packages/sso/src/routes/providers.ts +++ b/packages/sso/src/routes/providers.ts @@ -133,7 +133,6 @@ function sanitizeProvider( samlConfig: samlConfig ? { entryPoint: samlConfig.entryPoint, - callbackUrl: samlConfig.callbackUrl, audience: samlConfig.audience, wantAssertionsSigned: samlConfig.wantAssertionsSigned, authnRequestsSigned: samlConfig.authnRequestsSigned, @@ -343,7 +342,6 @@ function mergeSAMLConfig( issuer, entryPoint: updates.entryPoint ?? current.entryPoint, cert: updates.cert ?? current.cert, - callbackUrl: updates.callbackUrl ?? current.callbackUrl, spMetadata: updates.spMetadata ?? current.spMetadata, idpMetadata: updates.idpMetadata ?? current.idpMetadata, mapping: updates.mapping ?? current.mapping, diff --git a/packages/sso/src/routes/saml-pipeline.ts b/packages/sso/src/routes/saml-pipeline.ts index 0379c64d0d..62afac8320 100644 --- a/packages/sso/src/routes/saml-pipeline.ts +++ b/packages/sso/src/routes/saml-pipeline.ts @@ -120,8 +120,8 @@ export interface SAMLResponseParams { /** * Unified SAML response processing pipeline. * - * Both `/sso/saml2/callback/:providerId` (POST) and `/sso/saml2/sp/acs/:providerId` - * delegate to this function. It handles the full lifecycle: provider lookup, + * The `/sso/saml2/sp/acs/:providerId` endpoint delegates to this function. + * It handles the full lifecycle: provider lookup, * SP/IdP construction, response validation, session creation, and redirect * URL computation. */ @@ -195,7 +195,7 @@ export async function processSAMLResponse( const idp = createIdP(parsedSamlConfig); const samlRedirectUrl = getSafeRedirectUrl( - relayState?.callbackURL || parsedSamlConfig.callbackUrl, + relayState?.callbackURL, params.currentCallbackPath, appOrigin, (url: string, settings?: { allowRelativePaths: boolean }) => @@ -233,6 +233,10 @@ export async function processSAMLResponse( const { extract } = parsedResponse!; + // Destination validation (SAML Core §3.2.2) is handled by samlify's + // parseLoginResponse, which checks the Response Destination against the + // SP's registered ACS URL from the metadata. + // 10. Algorithm validation validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms); @@ -257,7 +261,7 @@ export async function processSAMLResponse( // 13. Audience restriction validation validateAudience(ctx, { extract: extract as SAMLAssertionExtract, - expectedAudience: parsedSamlConfig.audience, + expectedAudience: parsedSamlConfig.audience || sp.entityMeta.getEntityID(), providerId, redirectUrl: samlRedirectUrl, }); @@ -375,14 +379,7 @@ export async function processSAMLResponse( !!(provider as { domainVerified?: boolean }).domainVerified && validateEmailDomain(userInfo.email as string, provider.domain)); - // TODO: split callbackUrl into separate ACS URL and post-auth redirect - // fields. Currently callbackUrl serves both purposes, which means - // IdP-initiated flows (no RelayState) fall back to either a URL that may be - // the ACS endpoint (blocked by loop protection) or baseURL. - const callbackUrl = - relayState?.callbackURL || - parsedSamlConfig.callbackUrl || - ctx.context.baseURL; + const postAuthRedirect = relayState?.callbackURL || ctx.context.baseURL; const result = await handleOAuthUserInfo(ctx, { userInfo: { @@ -397,7 +394,7 @@ export async function processSAMLResponse( accessToken: "", refreshToken: "", }, - callbackURL: callbackUrl, + callbackURL: postAuthRedirect, disableSignUp: options?.disableImplicitSignUp, isTrustedProvider, }); @@ -477,7 +474,7 @@ export async function processSAMLResponse( // 21. Compute safe redirect URL return getSafeRedirectUrl( - relayState?.callbackURL || parsedSamlConfig.callbackUrl, + relayState?.callbackURL, currentCallbackPath, appOrigin, (url: string, settings?: { allowRelativePaths: boolean }) => diff --git a/packages/sso/src/routes/schemas.ts b/packages/sso/src/routes/schemas.ts index ec6d318d1e..5317f36422 100644 --- a/packages/sso/src/routes/schemas.ts +++ b/packages/sso/src/routes/schemas.ts @@ -45,7 +45,6 @@ const oidcConfigSchema = z.object({ const samlConfigSchema = z.object({ entryPoint: z.string().url().optional(), cert: z.string().optional(), - callbackUrl: z.string().url().optional(), audience: z.string().optional(), idpMetadata: z .object({ @@ -85,8 +84,6 @@ const samlConfigSchema = z.object({ digestAlgorithm: z.string().optional(), identifierFormat: z.string().optional(), privateKey: z.string().optional(), - decryptionPvk: z.string().optional(), - additionalParams: z.record(z.string(), z.any()).optional(), mapping: samlMappingSchema, }); diff --git a/packages/sso/src/routes/sso.ts b/packages/sso/src/routes/sso.ts index f184a72b39..e5b185ec59 100644 --- a/packages/sso/src/routes/sso.ts +++ b/packages/sso/src/routes/sso.ts @@ -22,9 +22,7 @@ import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies"; import { generateRandomString } from "better-auth/crypto"; import { handleOAuthUserInfo } from "better-auth/oauth2"; import { decodeJwt } from "jose"; -import * as saml from "samlify"; import type { BindingContext } from "samlify/types/src/entity"; -import type { IdentityProvider } from "samlify/types/src/entity-idp"; import * as z from "zod"; import * as constants from "../constants"; import { assignOrganizationFromProvider } from "../linking"; @@ -41,7 +39,6 @@ import { generateRelayState } from "../saml-state"; import type { AuthnRequestRecord, OIDCConfig, - SAMLAssertionExtract, SAMLConfig, SAMLSessionRecord, SSOOptions, @@ -84,7 +81,6 @@ function getOIDCRedirectURI( const spMetadataQuerySchema = z.object({ providerId: z.string(), - format: z.enum(["xml", "json"]).default("xml"), }); export const spMetadata = (options?: SSOOptions) => { @@ -132,42 +128,20 @@ export const spMetadata = (options?: SSOOptions) => { }); } - const sloLocation = `${ctx.context.baseURL}/sso/saml2/sp/slo/${ctx.query.providerId}`; - const singleLogoutService = options?.saml?.enableSingleLogout - ? [ - { - Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", - Location: sloLocation, - }, - { - Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", - Location: sloLocation, - }, - ] - : undefined; - - const sp = parsedSamlConfig.spMetadata.metadata - ? saml.ServiceProvider({ - metadata: parsedSamlConfig.spMetadata.metadata, - }) - : saml.SPMetadata({ - entityID: - parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer, - assertionConsumerService: [ - { - Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", - Location: - parsedSamlConfig.callbackUrl || - `${ctx.context.baseURL}/sso/saml2/sp/acs/${ctx.query.providerId}`, + const sp = createSP( + parsedSamlConfig, + ctx.context.baseURL, + ctx.query.providerId, + options?.saml?.enableSingleLogout + ? { + sloOptions: { + wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned, + wantLogoutResponseSigned: + options?.saml?.wantLogoutResponseSigned, }, - ], - singleLogoutService, - wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false, - authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false, - nameIDFormat: parsedSamlConfig.identifierFormat - ? [parsedSamlConfig.identifierFormat] - : undefined, - }); + } + : undefined, + ); return new Response(sp.getMetadata(), { headers: { "Content-Type": "application/xml", @@ -286,9 +260,6 @@ const ssoProviderBodySchema = z.object({ cert: z.string({}).meta({ description: "The certificate of the provider", }), - callbackUrl: z.string({}).meta({ - description: "The callback URL of the provider", - }), audience: z.string().optional(), idpMetadata: z .object({ @@ -317,24 +288,24 @@ const ssoProviderBodySchema = z.object({ }), }) .optional(), - spMetadata: z.object({ - metadata: z.string().optional(), - entityID: z.string().optional(), - binding: z.string().optional(), - privateKey: z.string().optional(), - privateKeyPass: z.string().optional(), - isAssertionEncrypted: z.boolean().optional(), - encPrivateKey: z.string().optional(), - encPrivateKeyPass: z.string().optional(), - }), + spMetadata: z + .object({ + metadata: z.string().optional(), + entityID: z.string().optional(), + binding: z.string().optional(), + privateKey: z.string().optional(), + privateKeyPass: z.string().optional(), + isAssertionEncrypted: z.boolean().optional(), + encPrivateKey: z.string().optional(), + encPrivateKeyPass: z.string().optional(), + }) + .optional(), wantAssertionsSigned: z.boolean().optional(), authnRequestsSigned: z.boolean().optional(), signatureAlgorithm: z.string().optional(), digestAlgorithm: z.string().optional(), identifierFormat: z.string().optional(), privateKey: z.string().optional(), - decryptionPvk: z.string().optional(), - additionalParams: z.record(z.string(), z.any()).optional(), mapping: z .object({ id: z.string({}).meta({ @@ -827,7 +798,6 @@ export const registerSSOProvider = (options: O) => { issuer: body.issuer, entryPoint: body.samlConfig.entryPoint, cert: body.samlConfig.cert, - callbackUrl: body.samlConfig.callbackUrl, audience: body.samlConfig.audience, idpMetadata: body.samlConfig.idpMetadata, spMetadata: body.samlConfig.spMetadata, @@ -837,8 +807,6 @@ export const registerSSOProvider = (options: O) => { digestAlgorithm: body.samlConfig.digestAlgorithm, identifierFormat: body.samlConfig.identifierFormat, privateKey: body.samlConfig.privateKey, - decryptionPvk: body.samlConfig.decryptionPvk, - additionalParams: body.samlConfig.additionalParams, mapping: body.samlConfig.mapping, }) : null, @@ -1283,72 +1251,13 @@ export const signInSSO = (options?: SSOOptions) => { false, ); - let metadata = parsedSamlConfig.spMetadata.metadata; - - if (!metadata) { - metadata = - saml - .SPMetadata({ - entityID: - parsedSamlConfig.spMetadata?.entityID || - parsedSamlConfig.issuer, - assertionConsumerService: [ - { - Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", - Location: - parsedSamlConfig.callbackUrl || - `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`, - }, - ], - wantMessageSigned: - parsedSamlConfig.wantAssertionsSigned || false, - authnRequestsSigned: - parsedSamlConfig.authnRequestsSigned || false, - nameIDFormat: parsedSamlConfig.identifierFormat - ? [parsedSamlConfig.identifierFormat] - : undefined, - }) - .getMetadata() || ""; - } - - const sp = saml.ServiceProvider({ - metadata: metadata, - allowCreate: true, - privateKey: - parsedSamlConfig.spMetadata?.privateKey || - parsedSamlConfig.privateKey, - privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass, - relayState, - }); - - const idpData = parsedSamlConfig.idpMetadata; - let idp: IdentityProvider; - if (!idpData?.metadata) { - idp = saml.IdentityProvider({ - entityID: idpData?.entityID || parsedSamlConfig.issuer, - singleSignOnService: idpData?.singleSignOnService || [ - { - Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", - Location: parsedSamlConfig.entryPoint, - }, - ], - signingCert: idpData?.cert || parsedSamlConfig.cert, - wantAuthnRequestsSigned: - parsedSamlConfig.authnRequestsSigned || false, - isAssertionEncrypted: idpData?.isAssertionEncrypted || false, - encPrivateKey: idpData?.encPrivateKey, - encPrivateKeyPass: idpData?.encPrivateKeyPass, - }); - } else { - idp = saml.IdentityProvider({ - metadata: idpData.metadata, - privateKey: idpData.privateKey, - privateKeyPass: idpData.privateKeyPass, - isAssertionEncrypted: idpData.isAssertionEncrypted, - encPrivateKey: idpData.encPrivateKey, - encPrivateKeyPass: idpData.encPrivateKeyPass, - }); - } + const sp = createSP( + parsedSamlConfig, + ctx.context.baseURL, + provider.providerId, + { relayState }, + ); + const idp = createIdP(parsedSamlConfig); const loginRequest = sp.createLoginRequest( idp, "redirect", @@ -1882,108 +1791,22 @@ export const callbackSSOShared = (options?: SSOOptions) => { ); }; -const callbackSSOSAMLBodySchema = z.object({ +const acsEndpointBodySchema = z.object({ SAMLResponse: z.string(), RelayState: z.string().optional(), }); -export const callbackSSOSAML = (options?: SSOOptions) => { +export const acsEndpoint = (options?: SSOOptions) => { return createAuthEndpoint( - "/sso/saml2/callback/:providerId", + "/sso/saml2/sp/acs/:providerId", { method: ["GET", "POST"], - body: callbackSSOSAMLBodySchema.optional(), + body: acsEndpointBodySchema.optional(), query: z .object({ RelayState: z.string().optional(), }) .optional(), - metadata: { - ...HIDE_METADATA, - allowedMediaTypes: [ - "application/x-www-form-urlencoded", - "application/json", - ], - openapi: { - operationId: "handleSAMLCallback", - summary: "Callback URL for SAML provider", - description: - "This endpoint is used as the callback URL for SAML providers. Supports both GET and POST methods for IdP-initiated and SP-initiated flows.", - responses: { - "302": { - description: "Redirects to the callback URL", - }, - "400": { - description: "Invalid SAML response", - }, - "401": { - description: "Unauthorized - SAML authentication failed", - }, - }, - }, - }, - }, - async (ctx) => { - const { providerId } = ctx.params; - const appOrigin = new URL(ctx.context.baseURL).origin; - const errorURL = - ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`; - const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/callback/${providerId}`; - - // Determine if this is a GET request by checking both method AND body presence - // When called via auth.api.*, ctx.method may not be reliable, so we also check for body - const isGetRequest = ctx.method === "GET" && !ctx.body?.SAMLResponse; - - if (isGetRequest) { - const session = await getSessionFromCtx(ctx); - - if (!session?.session) { - throw ctx.redirect(`${errorURL}?error=invalid_request`); - } - - const relayState = ctx.query?.RelayState as string | undefined; - const safeRedirectUrl = getSafeRedirectUrl( - relayState, - currentCallbackPath, - appOrigin, - (url, settings) => ctx.context.isTrustedOrigin(url, settings), - ); - - throw ctx.redirect(safeRedirectUrl); - } - - if (!ctx.body?.SAMLResponse) { - throw new APIError("BAD_REQUEST", { - message: "SAMLResponse is required for POST requests", - }); - } - - const safeRedirectUrl = await processSAMLResponse( - ctx, - { - SAMLResponse: ctx.body.SAMLResponse, - RelayState: ctx.body.RelayState, - providerId, - currentCallbackPath, - }, - options, - ); - throw ctx.redirect(safeRedirectUrl); - }, - ); -}; - -const acsEndpointBodySchema = z.object({ - SAMLResponse: z.string(), - RelayState: z.string().optional(), -}); - -export const acsEndpoint = (options?: SSOOptions) => { - return createAuthEndpoint( - "/sso/saml2/sp/acs/:providerId", - { - method: "POST", - body: acsEndpointBodySchema, metadata: { ...HIDE_METADATA, allowedMediaTypes: [ @@ -1994,11 +1817,17 @@ export const acsEndpoint = (options?: SSOOptions) => { operationId: "handleSAMLAssertionConsumerService", summary: "SAML Assertion Consumer Service", description: - "Handles SAML responses from IdP after successful authentication", + "Handles SAML responses from IdP after successful authentication. Supports GET for post-auth redirects and POST for SAML response processing.", responses: { "302": { description: - "Redirects to the callback URL after successful authentication", + "Redirects after authentication (success or error with query params)", + }, + "400": { + description: "Missing SAMLResponse in POST body", + }, + "404": { + description: "SAML provider not found", }, }, }, @@ -2009,6 +1838,33 @@ export const acsEndpoint = (options?: SSOOptions) => { const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`; const appOrigin = new URL(ctx.context.baseURL).origin; + // GET: post-auth redirect (e.g., after IdP-initiated flow completes) + const isGetRequest = ctx.method === "GET" && !ctx.body?.SAMLResponse; + if (isGetRequest) { + const session = await getSessionFromCtx(ctx); + if (!session?.session) { + const errorURL = + ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`; + throw ctx.redirect(`${errorURL}?error=invalid_request`); + } + const relayState = ctx.query?.RelayState as string | undefined; + throw ctx.redirect( + getSafeRedirectUrl( + relayState, + currentCallbackPath, + appOrigin, + (url, settings) => ctx.context.isTrustedOrigin(url, settings), + ), + ); + } + + // POST: SAML response processing + if (!ctx.body?.SAMLResponse) { + throw new APIError("BAD_REQUEST", { + message: "SAMLResponse is required for POST requests", + }); + } + try { const safeRedirectUrl = await processSAMLResponse( ctx, @@ -2022,7 +1878,6 @@ export const acsEndpoint = (options?: SSOOptions) => { ); throw ctx.redirect(safeRedirectUrl); } catch (error) { - // Re-throw redirects (they use throw for control flow) if ( error instanceof Response || (error && @@ -2032,21 +1887,10 @@ export const acsEndpoint = (options?: SSOOptions) => { ) { throw error; } - // Translate structural SAML errors (400) into browser-friendly redirects - // so the user returns to the app instead of seeing raw JSON. - // Non-400 errors (404 provider not found, 401 unauthorized) propagate as-is. if (error instanceof APIError && error.statusCode === 400) { - // TODO: unify error codes across endpoints (callbackSSOSAML uses - // the raw APIError code, ACS uses lowercase snake_case for backward compat) - const internalCode = error.body?.code || ""; - const errorCode = - internalCode === "SAML_MULTIPLE_ASSERTIONS" - ? "multiple_assertions" - : internalCode === "SAML_NO_ASSERTION" - ? "no_assertion" - : internalCode.toLowerCase() || "saml_error"; + const errorCode = (error.body?.code || "saml_error").toLowerCase(); const redirectUrl = getSafeRedirectUrl( - ctx.body.RelayState || undefined, + ctx.body?.RelayState || undefined, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings), @@ -2240,7 +2084,9 @@ async function handleLogoutRequest( } const { nameID } = parsed.extract; - const sessionIndex = (parsed.extract as SAMLAssertionExtract).sessionIndex; + // LogoutRequest SessionIndex is a plain string (unlike login response + // where samlify nests it as { sessionIndex: string } from AuthnStatement) + const sessionIndex = parsed.extract.sessionIndex as string | undefined; const key = `${constants.SAML_SESSION_KEY_PREFIX}${providerId}:${nameID}`; const stored = await ctx.context.internalAdapter.findVerificationValue(key); diff --git a/packages/sso/src/saml.test.ts b/packages/sso/src/saml.test.ts index a4e92aa8e4..e44ececcf9 100644 --- a/packages/sso/src/saml.test.ts +++ b/packages/sso/src/saml.test.ts @@ -533,7 +533,7 @@ const createMockSAMLIdP = (port: number, options: MockIdPOptions = {}) => { } }); app.post( - "/api/sso/saml2/callback/:providerId", + "/api/sso/saml2/sp/acs/:providerId", async (req: ExpressRequest, res: ExpressResponse) => { const { SAMLResponse, RelayState } = req.body; try { @@ -609,7 +609,6 @@ describe("SAML SSO with defaultSSO array", async () => { issuer: "http://localhost:8081", entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:8081/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -703,7 +702,6 @@ describe("SAML SSO with signed AuthnRequests", async () => { issuer: "http://localhost:8082", entryPoint: "http://localhost:8082/api/sso/saml2/idp/redirect", cert: certificate, - callbackUrl: "http://localhost:8082/dashboard", wantAssertionsSigned: false, authnRequestsSigned: true, signatureAlgorithm: "sha256", @@ -809,7 +807,6 @@ describe("SAML SSO with signed AuthnRequests", async () => { issuer: "http://localhost:8082", entryPoint: "http://localhost:8082/api/sso/saml2/idp/redirect", cert: certificate, - callbackUrl: "http://localhost:8082/dashboard", authnRequestsSigned: true, spMetadata: {}, idpMetadata: { @@ -855,7 +852,6 @@ describe("SAML SSO without signed AuthnRequests", async () => { issuer: "http://localhost:8082", entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:8082/dashboard", wantAssertionsSigned: false, authnRequestsSigned: false, signatureAlgorithm: "sha256", @@ -926,7 +922,6 @@ describe("SAML SSO with idpMetadata but without metadata XML (fallback to top-le issuer: "http://localhost:8083/issuer", entryPoint: "http://localhost:8081/api/sso/saml2/idp/redirect", cert: certificate, - callbackUrl: "http://localhost:8083/dashboard", wantAssertionsSigned: false, authnRequestsSigned: false, spMetadata: {}, @@ -1089,7 +1084,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: sharedMockIdP.metadataUrl, cert: certificate, - callbackUrl: "http://localhost:8081/api/sso/saml2/callback", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -1122,7 +1116,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: sharedMockIdP.metadataUrl, cert: expect.any(String), - callbackUrl: "http://localhost:8081/api/sso/saml2/callback", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -1145,7 +1138,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: sharedMockIdP.metadataUrl, cert: certificate, - callbackUrl: "http://localhost:8081/api/sso/saml2/sp/acs", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -1197,7 +1189,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: sharedMockIdP.metadataUrl, cert: certificate, - callbackUrl: `${issuer}/api/sso/saml2/sp/acs`, wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -1242,7 +1233,7 @@ describe("SAML SSO", async () => { ` { @@ -1259,7 +1250,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:8081/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -1306,7 +1296,7 @@ describe("SAML SSO", async () => { }); let redirectLocation = ""; await betterFetch( - "http://localhost:8081/api/sso/saml2/callback/saml-provider-1", + "http://localhost:8081/api/sso/saml2/sp/acs/saml-provider-1", { method: "POST", redirect: "manual", @@ -1339,7 +1329,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: sharedMockIdP.metadataUrl, cert: certificate, - callbackUrl: "http://localhost:8081/api/sso/saml2/callback", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -1370,7 +1359,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: sharedMockIdP.metadataUrl, cert: certificate, - callbackUrl: "http://localhost:8081/api/sso/saml2/callback", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -1391,7 +1379,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: sharedMockIdP.metadataUrl, cert: certificate, - callbackUrl: "http://localhost:8081/api/sso/saml2/callback", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -1430,7 +1417,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: sharedMockIdP.metadataUrl, cert: certificate, - callbackUrl: "http://localhost:8081/api/sso/saml2/callback", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -1451,7 +1437,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: sharedMockIdP.metadataUrl, cert: certificate, - callbackUrl: "http://localhost:8081/api/sso/saml2/callback", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -1485,7 +1470,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: sharedMockIdP.metadataUrl, cert: certificate, - callbackUrl: "http://localhost:8081/api/sso/saml2/callback", spMetadata: { metadata: spMetadata, }, @@ -1503,7 +1487,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: sharedMockIdP.metadataUrl, cert: certificate, - callbackUrl: "http://localhost:8082/api/sso/saml2/callback", spMetadata: { metadata: spMetadata, }, @@ -1533,7 +1516,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -1572,7 +1554,7 @@ describe("SAML SSO", async () => { }); const samlRedirectUrl = new URL(signInResponse?.url); - const callbackResponse = await auth.api.callbackSSOSAML({ + const callbackResponse = await auth.api.acsEndpoint({ method: "POST", body: { SAMLResponse: samlResponse.samlResponse, @@ -1590,7 +1572,7 @@ describe("SAML SSO", async () => { expect(callbackResponse.headers.get("location")).toContain("dashboard"); }); - it("should initiate SAML login and fallback to callbackUrl on invalid RelayState", async () => { + it("should initiate SAML login and fallback to baseURL on invalid RelayState", async () => { const { auth, signInWithTestUser } = await getTestInstance({ plugins: [sso({ saml: { enableInResponseToValidation: false } })], }); @@ -1604,7 +1586,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -1642,7 +1623,7 @@ describe("SAML SSO", async () => { }, }); - const callbackResponse = await auth.api.callbackSSOSAML({ + const callbackResponse = await auth.api.acsEndpoint({ method: "POST", body: { SAMLResponse: samlResponse.samlResponse, @@ -1659,7 +1640,7 @@ describe("SAML SSO", async () => { expect(callbackResponse.status).toBe(302); expect(callbackResponse.headers.get("location")).toBe( - "http://localhost:3000/dashboard", + "http://localhost:3000", ); }); @@ -1677,7 +1658,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -1717,7 +1697,7 @@ describe("SAML SSO", async () => { }); const samlRedirectUrl = new URL(signInResponse?.url); - const callbackResponse = await auth.api.callbackSSOSAML({ + const callbackResponse = await auth.api.acsEndpoint({ method: "POST", body: { SAMLResponse: samlResponse.samlResponse, @@ -1757,7 +1737,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -1785,7 +1764,7 @@ describe("SAML SSO", async () => { const response = await authWithDisabledSignUp.handler( new Request( - "http://localhost:3000/api/auth/sso/saml2/callback/saml-test-provider", + "http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-test-provider", { method: "POST", headers: { @@ -1825,7 +1804,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -1891,7 +1869,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -1930,7 +1907,7 @@ describe("SAML SSO", async () => { const response = await authUntrusted.handler( new Request( - "http://localhost:3000/api/auth/sso/saml2/callback/untrusted-saml-provider", + "http://localhost:3000/api/auth/sso/saml2/sp/acs/untrusted-saml-provider", { method: "POST", headers: { @@ -1971,7 +1948,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -2010,7 +1986,7 @@ describe("SAML SSO", async () => { const response = await authWithTrusted.handler( new Request( - "http://localhost:3000/api/auth/sso/saml2/callback/trusted-saml-provider", + "http://localhost:3000/api/auth/sso/saml2/sp/acs/trusted-saml-provider", { method: "POST", headers: { @@ -2026,7 +2002,7 @@ describe("SAML SSO", async () => { expect(response.status).toBe(302); const redirectLocation = response.headers.get("location") || ""; expect(redirectLocation).not.toContain("error"); - expect(redirectLocation).toContain("dashboard"); + expect(redirectLocation).toBe("http://localhost:3000"); }); it("should reject unsolicited SAML response when allowIdpInitiated is false", async () => { @@ -2051,7 +2027,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -2077,7 +2052,7 @@ describe("SAML SSO", async () => { const response = await auth.handler( new Request( - "http://localhost:3000/api/auth/sso/saml2/callback/strict-saml-provider", + "http://localhost:3000/api/auth/sso/saml2/sp/acs/strict-saml-provider", { method: "POST", headers: { @@ -2117,7 +2092,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -2143,7 +2117,7 @@ describe("SAML SSO", async () => { const response = await auth.handler( new Request( - "http://localhost:3000/api/auth/sso/saml2/callback/permissive-saml-provider", + "http://localhost:3000/api/auth/sso/saml2/sp/acs/permissive-saml-provider", { method: "POST", headers: { @@ -2182,7 +2156,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -2208,7 +2181,7 @@ describe("SAML SSO", async () => { const response = await auth.handler( new Request( - "http://localhost:3000/api/auth/sso/saml2/callback/legacy-saml-provider", + "http://localhost:3000/api/auth/sso/saml2/sp/acs/legacy-saml-provider", { method: "POST", headers: { @@ -2248,8 +2221,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: - "http://localhost:3000/api/auth/sso/saml2/callback/saml-provider", audience: "http://localhost:3000", wantAssertionsSigned: false, signatureAlgorithm: "sha256", @@ -2278,7 +2249,7 @@ describe("SAML SSO", async () => { const response = await auth.handler( new Request( - "http://localhost:3000/api/auth/sso/saml2/callback/saml-provider", + "http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-provider", { method: "POST", headers: { @@ -2321,7 +2292,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -2349,7 +2319,7 @@ describe("SAML SSO", async () => { const response = await auth.handler( new Request( - "http://localhost:3000/api/auth/sso/saml2/callback/db-fallback-provider", + "http://localhost:3000/api/auth/sso/saml2/sp/acs/db-fallback-provider", { method: "POST", headers: { @@ -2392,7 +2362,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", audience: "https://wrong-audience.example.com", wantAssertionsSigned: false, signatureAlgorithm: "sha256", @@ -2418,7 +2387,7 @@ describe("SAML SSO", async () => { const response = await auth.handler( new Request( - "http://localhost:3000/api/auth/sso/saml2/callback/audience-mismatch-provider", + "http://localhost:3000/api/auth/sso/saml2/sp/acs/audience-mismatch-provider", { method: "POST", headers: { @@ -2461,7 +2430,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", audience: "http://localhost:3001/api/sso/saml2/sp/metadata", wantAssertionsSigned: false, signatureAlgorithm: "sha256", @@ -2487,7 +2455,7 @@ describe("SAML SSO", async () => { const response = await auth.handler( new Request( - "http://localhost:3000/api/auth/sso/saml2/callback/audience-match-provider", + "http://localhost:3000/api/auth/sso/saml2/sp/acs/audience-match-provider", { method: "POST", headers: { @@ -2524,7 +2492,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -2609,8 +2576,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: - "http://localhost:3000/api/auth/sso/saml2/callback/saml-provider", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -2651,7 +2616,7 @@ describe("SAML SSO", async () => { // IdP POSTs back without relay_state cookie (SameSite=Lax blocks cross-site POST cookies) const callbackResponse = await auth.handler( new Request( - "http://localhost:3000/api/auth/sso/saml2/callback/saml-provider", + "http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-provider", { method: "POST", headers: { @@ -2668,7 +2633,7 @@ describe("SAML SSO", async () => { expect(callbackResponse.status).toBe(302); const redirectLocation = callbackResponse.headers.get("location") || ""; expect(redirectLocation).toContain("/dashboard"); - expect(redirectLocation).not.toContain("/sso/saml2/callback/"); + expect(redirectLocation).not.toContain("/sso/saml2/sp/acs/"); expect(redirectLocation).not.toContain("error"); }); @@ -2687,8 +2652,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: - "http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-provider", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -2752,7 +2715,7 @@ describe("SAML SSO", async () => { /** * @see https://github.com/better-auth/better-auth/issues/7777 */ - it("should fallback to provider callbackUrl on ACS route when RelayState is invalid", async () => { + it("should fallback to baseURL on ACS route when RelayState is invalid", async () => { const { auth, signInWithTestUser } = await getTestInstance({ plugins: [sso()], }); @@ -2767,7 +2730,6 @@ describe("SAML SSO", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -2791,7 +2753,7 @@ describe("SAML SSO", async () => { }, }); - // POST with a garbage RelayState - should fallback to provider callbackUrl + // POST with a garbage RelayState - should fallback to baseURL const acsResponse = await auth.handler( new Request( "http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-acs-bad-relay-provider", @@ -2810,8 +2772,8 @@ describe("SAML SSO", async () => { expect(acsResponse.status).toBe(302); const location = acsResponse.headers.get("location") || ""; - // Should redirect to the provider's callbackUrl, not the garbage RelayState - expect(location).toContain("dashboard"); + // Should redirect to baseURL with error, not the garbage RelayState + expect(location).toContain("http://localhost:3000"); expect(location).not.toContain("not-a-valid-relay-state"); }); }); @@ -2906,7 +2868,6 @@ describe("SAML SSO with custom fields", () => { samlConfig: { entryPoint: sharedMockIdP.metadataUrl, cert: certificate, - callbackUrl: "http://localhost:8081/api/sso/saml2/callback", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -2939,7 +2900,6 @@ describe("SAML SSO with custom fields", () => { samlConfig: { entryPoint: sharedMockIdP.metadataUrl, cert: expect.any(String), - callbackUrl: "http://localhost:8081/api/sso/saml2/callback", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -3041,7 +3001,6 @@ describe("SSO Provider Config Parsing", () => { samlConfig: { entryPoint: "http://localhost:8081/sso", cert: "test-cert", - callbackUrl: "http://localhost:3000/callback", spMetadata: { entityID: "test-entity", }, @@ -3160,7 +3119,6 @@ describe("SAML SSO - IdP Initiated Flow", () => { "/idp/post", ), cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -3193,7 +3151,7 @@ describe("SAML SSO - IdP Initiated Flow", () => { throw new Error("Failed to get SAML response from mock IdP"); } - const postResponse = await auth.api.callbackSSOSAML({ + const postResponse = await auth.api.acsEndpoint({ method: "POST", body: { SAMLResponse: samlResponse.samlResponse, @@ -3208,10 +3166,10 @@ describe("SAML SSO - IdP Initiated Flow", () => { expect(postResponse).toBeInstanceOf(Response); expect(postResponse.status).toBe(302); const redirectLocation = postResponse.headers.get("location"); - expect(redirectLocation).toBe("http://localhost:3000/dashboard"); + expect(redirectLocation).toBe("http://localhost:3000"); const cookieHeader = postResponse.headers.get("set-cookie"); - const getResponse = await auth.api.callbackSSOSAML({ + const getResponse = await auth.api.acsEndpoint({ method: "GET", query: { RelayState: "http://localhost:3000/dashboard", @@ -3235,7 +3193,7 @@ describe("SAML SSO - IdP Initiated Flow", () => { }); const getResponse = await auth.api - .callbackSSOSAML({ + .acsEndpoint({ method: "GET", params: { providerId: "test-provider", @@ -3259,7 +3217,7 @@ describe("SAML SSO - IdP Initiated Flow", () => { expect(redirectLocation).toContain("error=invalid_request"); }); - it("should prevent redirect loop when callbackUrl points to callback route", async () => { + it("should redirect to baseURL after ACS, not back to the ACS route", async () => { const { auth, signInWithTestUser } = await getTestInstance({ plugins: [sso({ saml: { enableInResponseToValidation: false } })], }); @@ -3267,7 +3225,7 @@ describe("SAML SSO - IdP Initiated Flow", () => { const { headers } = await signInWithTestUser(); const callbackRouteUrl = - "http://localhost:3000/api/auth/sso/saml2/callback/loop-test-provider"; + "http://localhost:3000/api/auth/sso/saml2/sp/acs/loop-test-provider"; await auth.api.registerSSOProvider({ body: { @@ -3280,7 +3238,6 @@ describe("SAML SSO - IdP Initiated Flow", () => { "/idp/post", ), cert: certificate, - callbackUrl: callbackRouteUrl, wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -3313,7 +3270,7 @@ describe("SAML SSO - IdP Initiated Flow", () => { throw new Error("Failed to get SAML response from mock IdP"); } - const postResponse = await auth.api.callbackSSOSAML({ + const postResponse = await auth.api.acsEndpoint({ method: "POST", body: { SAMLResponse: samlResponse.samlResponse, @@ -3349,7 +3306,6 @@ describe("SAML SSO - IdP Initiated Flow", () => { "/idp/post", ), cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -3382,7 +3338,7 @@ describe("SAML SSO - IdP Initiated Flow", () => { throw new Error("Failed to get SAML response from mock IdP"); } - const postResponse = await auth.api.callbackSSOSAML({ + const postResponse = await auth.api.acsEndpoint({ method: "POST", body: { SAMLResponse: samlResponse.samlResponse, @@ -3395,7 +3351,7 @@ describe("SAML SSO - IdP Initiated Flow", () => { }); const cookieHeader = postResponse.headers.get("set-cookie"); - const getResponse = await auth.api.callbackSSOSAML({ + const getResponse = await auth.api.acsEndpoint({ method: "GET", query: { RelayState: "http://localhost:3000/custom-path", @@ -3421,7 +3377,7 @@ describe("SAML SSO - IdP Initiated Flow", () => { const { headers } = await signInWithTestUser(); const callbackRouteUrl = - "http://localhost:3000/api/auth/sso/saml2/callback/issue-6615-provider"; + "http://localhost:3000/api/auth/sso/saml2/sp/acs/issue-6615-provider"; await auth.api.registerSSOProvider({ body: { @@ -3434,7 +3390,6 @@ describe("SAML SSO - IdP Initiated Flow", () => { "/idp/post", ), cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -3467,7 +3422,7 @@ describe("SAML SSO - IdP Initiated Flow", () => { throw new Error("Failed to get SAML response from mock IdP"); } - const postResponse = await auth.api.callbackSSOSAML({ + const postResponse = await auth.api.acsEndpoint({ method: "POST", body: { SAMLResponse: samlResponse.samlResponse, @@ -3483,10 +3438,10 @@ describe("SAML SSO - IdP Initiated Flow", () => { expect(postResponse.status).toBe(302); const postRedirectLocation = postResponse.headers.get("location"); expect(postRedirectLocation).not.toBe(callbackRouteUrl); - expect(postRedirectLocation).toBe("http://localhost:3000/dashboard"); + expect(postRedirectLocation).toBe("http://localhost:3000"); const cookieHeader = postResponse.headers.get("set-cookie"); - const getResponse = await auth.api.callbackSSOSAML({ + const getResponse = await auth.api.acsEndpoint({ method: "GET", params: { providerId: "issue-6615-provider", @@ -3519,7 +3474,6 @@ describe("SAML SSO - IdP Initiated Flow", () => { "/idp/post", ), cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -3553,8 +3507,7 @@ describe("SAML SSO - IdP Initiated Flow", () => { } // Test POST with malicious RelayState - raw RelayState is not trusted - // Falls back to parsedSamlConfig.callbackUrl - const postResponse = await auth.api.callbackSSOSAML({ + const postResponse = await auth.api.acsEndpoint({ method: "POST", body: { SAMLResponse: samlResponse.samlResponse, @@ -3571,8 +3524,8 @@ describe("SAML SSO - IdP Initiated Flow", () => { const postRedirectLocation = postResponse.headers.get("location"); // Should NOT redirect to evil.com - raw RelayState is ignored expect(postRedirectLocation).not.toContain("evil.com"); - // Falls back to samlConfig.callbackUrl - expect(postRedirectLocation).toBe("http://localhost:3000/dashboard"); + // Falls back to baseURL + expect(postRedirectLocation).toBe("http://localhost:3000"); }); it("should prevent open redirect via GET with malicious RelayState", async () => { @@ -3593,7 +3546,6 @@ describe("SAML SSO - IdP Initiated Flow", () => { "/idp/post", ), cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -3627,7 +3579,7 @@ describe("SAML SSO - IdP Initiated Flow", () => { } // First do POST to establish session - const postResponse = await auth.api.callbackSSOSAML({ + const postResponse = await auth.api.acsEndpoint({ method: "POST", body: { SAMLResponse: samlResponse.samlResponse, @@ -3641,7 +3593,7 @@ describe("SAML SSO - IdP Initiated Flow", () => { const cookieHeader = postResponse.headers.get("set-cookie"); // Test GET with malicious RelayState in query params - const getResponse = await auth.api.callbackSSOSAML({ + const getResponse = await auth.api.acsEndpoint({ method: "GET", query: { RelayState: "https://evil.com/steal-cookies", @@ -3679,7 +3631,6 @@ describe("SAML SSO - IdP Initiated Flow", () => { "/idp/post", ), cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -3712,7 +3663,7 @@ describe("SAML SSO - IdP Initiated Flow", () => { throw new Error("Failed to get SAML response from mock IdP"); } - const postResponse = await auth.api.callbackSSOSAML({ + const postResponse = await auth.api.acsEndpoint({ method: "POST", body: { SAMLResponse: samlResponse.samlResponse, @@ -3727,7 +3678,7 @@ describe("SAML SSO - IdP Initiated Flow", () => { expect(postResponse).toBeInstanceOf(Response); expect(postResponse.status).toBe(302); const redirectLocation = postResponse.headers.get("location"); - expect(redirectLocation).toBe("http://localhost:3000/dashboard"); + expect(redirectLocation).toBe("http://localhost:3000"); }); it("should block protocol-relative URL attacks (//evil.com)", async () => { @@ -3748,7 +3699,6 @@ describe("SAML SSO - IdP Initiated Flow", () => { "/idp/post", ), cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -3782,8 +3732,7 @@ describe("SAML SSO - IdP Initiated Flow", () => { } // Test POST with protocol-relative URL - raw RelayState is not trusted - // Falls back to parsedSamlConfig.callbackUrl - const postResponse = await auth.api.callbackSSOSAML({ + const postResponse = await auth.api.acsEndpoint({ method: "POST", body: { SAMLResponse: samlResponse.samlResponse, @@ -3800,8 +3749,8 @@ describe("SAML SSO - IdP Initiated Flow", () => { const redirectLocation = postResponse.headers.get("location"); // Should NOT redirect to evil.com - raw RelayState is ignored expect(redirectLocation).not.toContain("evil.com"); - // Falls back to samlConfig.callbackUrl - expect(redirectLocation).toBe("http://localhost:3000/dashboard"); + // Falls back to baseURL + expect(redirectLocation).toBe("http://localhost:3000"); }); }); @@ -4051,7 +4000,6 @@ describe("SAML ACS Origin Check Bypass", () => { samlConfig: { entryPoint: sharedMockIdP.metadataUrl, cert: certificate, - callbackUrl: "http://localhost:8081/api/auth/sso/saml2/callback", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -4067,7 +4015,7 @@ describe("SAML ACS Origin Check Bypass", () => { // Origin check should be bypassed for SAML callback endpoints const callbackRes = await auth.handler( new Request( - "http://localhost:8081/api/auth/sso/saml2/callback/origin-bypass-callback", + "http://localhost:8081/api/auth/sso/saml2/sp/acs/origin-bypass-callback", { method: "POST", headers: { @@ -4105,7 +4053,6 @@ describe("SAML ACS Origin Check Bypass", () => { samlConfig: { entryPoint: sharedMockIdP.metadataUrl, cert: certificate, - callbackUrl: "http://localhost:8081/api/auth/sso/saml2/sp/acs", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -4206,7 +4153,6 @@ describe("SAML ACS Origin Check Bypass", () => { samlConfig: { entryPoint: sharedMockIdP.metadataUrl, cert: certificate, - callbackUrl: "http://localhost:8081/api/auth/sso/saml2/callback", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -4221,7 +4167,7 @@ describe("SAML ACS Origin Check Bypass", () => { // Even with origin bypass, malicious RelayState should be rejected const callbackRes = await auth.handler( new Request( - "http://localhost:8081/api/auth/sso/saml2/callback/relay-security-test", + "http://localhost:8081/api/auth/sso/saml2/sp/acs/relay-security-test", { method: "POST", headers: { @@ -4262,7 +4208,6 @@ describe("SAML Response Security", () => { samlConfig: { entryPoint: sharedMockIdP.metadataUrl, cert: certificate, - callbackUrl: "http://localhost:8081/api/auth/sso/saml2/callback", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -4286,7 +4231,7 @@ describe("SAML Response Security", () => { const callbackRes = await auth.handler( new Request( - "http://localhost:8081/api/auth/sso/saml2/callback/security-test-provider", + "http://localhost:8081/api/auth/sso/saml2/sp/acs/security-test-provider", { method: "POST", headers: { @@ -4300,9 +4245,9 @@ describe("SAML Response Security", () => { ), ); - expect(callbackRes.status).toBe(400); - const body = await callbackRes.json(); - expect(body.message).toBe("Invalid SAML response"); + expect(callbackRes.status).toBe(302); + const location = callbackRes.headers.get("location") || ""; + expect(location).toContain("error="); }); it("should reject SAML response with tampered nameID", async () => { @@ -4319,7 +4264,6 @@ describe("SAML Response Security", () => { samlConfig: { entryPoint: sharedMockIdP.metadataUrl, cert: certificate, - callbackUrl: "http://localhost:8081/api/auth/sso/saml2/callback", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -4338,7 +4282,7 @@ describe("SAML Response Security", () => { const callbackRes = await auth.handler( new Request( - "http://localhost:8081/api/auth/sso/saml2/callback/tamper-test-provider", + "http://localhost:8081/api/auth/sso/saml2/sp/acs/tamper-test-provider", { method: "POST", headers: { @@ -4352,7 +4296,9 @@ describe("SAML Response Security", () => { ), ); - expect(callbackRes.status).toBe(400); + expect(callbackRes.status).toBe(302); + const location = callbackRes.headers.get("location") || ""; + expect(location).toContain("error="); }); }); @@ -4382,7 +4328,6 @@ describe("SAML SSO - Assertion Replay Protection", () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -4408,7 +4353,7 @@ describe("SAML SSO - Assertion Replay Protection", () => { const firstResponse = await auth.handler( new Request( - "http://localhost:3000/api/auth/sso/saml2/callback/replay-test-provider", + "http://localhost:3000/api/auth/sso/saml2/sp/acs/replay-test-provider", { method: "POST", headers: { @@ -4428,7 +4373,7 @@ describe("SAML SSO - Assertion Replay Protection", () => { const replayResponse = await auth.handler( new Request( - "http://localhost:3000/api/auth/sso/saml2/callback/replay-test-provider", + "http://localhost:3000/api/auth/sso/saml2/sp/acs/replay-test-provider", { method: "POST", headers: { @@ -4462,7 +4407,6 @@ describe("SAML SSO - Assertion Replay Protection", () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -4542,7 +4486,6 @@ describe("SAML SSO - Assertion Replay Protection", () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -4568,7 +4511,7 @@ describe("SAML SSO - Assertion Replay Protection", () => { const callbackResponse = await auth.handler( new Request( - "http://localhost:3000/api/auth/sso/saml2/callback/cross-endpoint-provider", + "http://localhost:3000/api/auth/sso/saml2/sp/acs/cross-endpoint-provider", { method: "POST", headers: { @@ -4623,7 +4566,6 @@ describe("SAML SSO - Single Assertion Validation", () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -4665,21 +4607,20 @@ describe("SAML SSO - Single Assertion Validation", () => { "base64", ); - await expect( - auth.api.callbackSSOSAML({ - body: { - SAMLResponse: encodedResponse, - RelayState: "http://localhost:3000/dashboard", - }, - params: { - providerId: "multi-assertion-callback-provider", - }, - }), - ).rejects.toMatchObject({ + const response = await auth.api.acsEndpoint({ body: { - code: "SAML_MULTIPLE_ASSERTIONS", + SAMLResponse: encodedResponse, + RelayState: "http://localhost:3000/dashboard", }, + params: { + providerId: "multi-assertion-callback-provider", + }, + asResponse: true, }); + + expect(response.status).toBe(302); + const location = response.headers.get("location") || ""; + expect(location).toContain("error=saml_multiple_assertions"); }); it("should reject SAML response with multiple assertions on ACS endpoint", async () => { @@ -4697,7 +4638,6 @@ describe("SAML SSO - Single Assertion Validation", () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -4758,7 +4698,7 @@ describe("SAML SSO - Single Assertion Validation", () => { // ACS endpoint translates structural errors into browser-friendly redirects expect(response.status).toBe(302); const location = response.headers.get("location") || ""; - expect(location).toContain("error=multiple_assertions"); + expect(location).toContain("error=saml_multiple_assertions"); }); it("should reject SAML response with no assertions", async () => { @@ -4776,7 +4716,6 @@ describe("SAML SSO - Single Assertion Validation", () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -4804,21 +4743,20 @@ describe("SAML SSO - Single Assertion Validation", () => { const encodedResponse = Buffer.from(noAssertionResponse).toString("base64"); - await expect( - auth.api.callbackSSOSAML({ - body: { - SAMLResponse: encodedResponse, - RelayState: "http://localhost:3000/dashboard", - }, - params: { - providerId: "no-assertion-provider", - }, - }), - ).rejects.toMatchObject({ + const response = await auth.api.acsEndpoint({ body: { - code: "SAML_NO_ASSERTION", + SAMLResponse: encodedResponse, + RelayState: "http://localhost:3000/dashboard", }, + params: { + providerId: "no-assertion-provider", + }, + asResponse: true, }); + + expect(response.status).toBe(302); + const location = response.headers.get("location") || ""; + expect(location).toContain("error=saml_no_assertion"); }); it("should reject SAML response with XSW-style assertion injection in Extensions", async () => { @@ -4836,7 +4774,6 @@ describe("SAML SSO - Single Assertion Validation", () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -4879,21 +4816,20 @@ describe("SAML SSO - Single Assertion Validation", () => { const encodedResponse = Buffer.from(xswInjectionResponse).toString("base64"); - await expect( - auth.api.callbackSSOSAML({ - body: { - SAMLResponse: encodedResponse, - RelayState: "http://localhost:3000/dashboard", - }, - params: { - providerId: "xsw-injection-provider", - }, - }), - ).rejects.toMatchObject({ + const response = await auth.api.acsEndpoint({ body: { - code: "SAML_MULTIPLE_ASSERTIONS", + SAMLResponse: encodedResponse, + RelayState: "http://localhost:3000/dashboard", }, + params: { + providerId: "xsw-injection-provider", + }, + asResponse: true, }); + + expect(response.status).toBe(302); + const location = response.headers.get("location") || ""; + expect(location).toContain("error=saml_multiple_assertions"); }); it("should accept valid SAML response with exactly one assertion", async () => { @@ -4911,7 +4847,6 @@ describe("SAML SSO - Single Assertion Validation", () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -4937,7 +4872,7 @@ describe("SAML SSO - Single Assertion Validation", () => { const response = await auth.handler( new Request( - "http://localhost:3000/api/auth/sso/saml2/callback/single-assertion-provider", + "http://localhost:3000/api/auth/sso/saml2/sp/acs/single-assertion-provider", { method: "POST", headers: { @@ -4970,7 +4905,6 @@ describe("SAML SSO - Single Assertion Validation", () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -5006,7 +4940,7 @@ describe("SAML SSO - Single Assertion Validation", () => { const firstCallbackResponse = await auth.handler( new Request( - "http://localhost:3000/api/auth/sso/saml2/callback/email-case-provider", + "http://localhost:3000/api/auth/sso/saml2/sp/acs/email-case-provider", { method: "POST", headers: { @@ -5021,9 +4955,6 @@ describe("SAML SSO - Single Assertion Validation", () => { ); expect(firstCallbackResponse.status).toBe(302); - expect(firstCallbackResponse.headers.get("location")).toContain( - "dashboard", - ); expect(firstCallbackResponse.headers.get("location")).not.toContain( "error", ); @@ -5060,7 +4991,7 @@ describe("SAML SSO - Single Assertion Validation", () => { const secondCallbackResponse = await auth.handler( new Request( - "http://localhost:3000/api/auth/sso/saml2/callback/email-case-provider", + "http://localhost:3000/api/auth/sso/saml2/sp/acs/email-case-provider", { method: "POST", headers: { @@ -5075,9 +5006,6 @@ describe("SAML SSO - Single Assertion Validation", () => { ); expect(secondCallbackResponse.status).toBe(302); - expect(secondCallbackResponse.headers.get("location")).toContain( - "dashboard", - ); expect(secondCallbackResponse.headers.get("location")).not.toContain( "error", ); @@ -5143,7 +5071,6 @@ describe("SAML Single Logout (SLO)", () => { samlConfig: { entryPoint: "http://localhost:8081/sso", cert: certificate, - callbackUrl: "http://localhost:8081/callback", spMetadata: { metadata: spMetadata, }, @@ -5225,7 +5152,6 @@ describe("SAML Single Logout (SLO)", () => { samlConfig: { entryPoint: "http://localhost:8081/sso", cert: certificate, - callbackUrl: "http://localhost:8081/callback", spMetadata: { metadata: spMetadata, }, @@ -5276,7 +5202,6 @@ describe("SAML Single Logout (SLO)", () => { samlConfig: { entryPoint: "http://localhost:8081/sso", cert: certificate, - callbackUrl: "http://localhost:8081/callback", spMetadata: { entityID: "http://localhost:8081/sp", }, @@ -5314,7 +5239,6 @@ describe("SAML Single Logout (SLO)", () => { samlConfig: { entryPoint: "http://localhost:8081/sso", cert: certificate, - callbackUrl: "http://localhost:8081/callback", spMetadata: { entityID: "http://localhost:8081/sp", }, @@ -5353,7 +5277,6 @@ describe("SAML Single Logout (SLO)", () => { samlConfig: { entryPoint: "http://localhost:8081/sso", cert: certificate, - callbackUrl: "http://localhost:8081/callback", spMetadata: { metadata: spMetadata, }, @@ -5405,7 +5328,6 @@ describe("SAML Single Logout (SLO)", () => { samlConfig: { entryPoint: "http://localhost:8081/sso", cert: certificate, - callbackUrl: "http://localhost:8081/callback", spMetadata: { metadata: spMetadata, }, @@ -5464,7 +5386,6 @@ describe("SAML Single Logout (SLO)", () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:8081/callback", spMetadata: { metadata: spMetadata, }, @@ -5548,7 +5469,6 @@ describe("SAML Single Logout (SLO)", () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:8081/callback", spMetadata: { metadata: spMetadata, }, @@ -5632,7 +5552,6 @@ describe("SAML Single Logout (SLO)", () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:8081/callback", spMetadata: { metadata: spMetadata, }, @@ -5715,7 +5634,6 @@ describe("SAML provisionUser should only be called for new users", async () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -5751,7 +5669,7 @@ describe("SAML provisionUser should only be called for new users", async () => { }); const samlRedirectUrl1 = new URL(response1.response?.url); - await auth.api.callbackSSOSAML({ + await auth.api.acsEndpoint({ method: "POST", body: { SAMLResponse: samlResponse.samlResponse, @@ -5787,7 +5705,7 @@ describe("SAML provisionUser should only be called for new users", async () => { }); const samlRedirectUrl2 = new URL(response2.response?.url); - await auth.api.callbackSSOSAML({ + await auth.api.acsEndpoint({ method: "POST", body: { SAMLResponse: samlResponse2.samlResponse, @@ -5831,7 +5749,6 @@ describe("SAML provisionUserOnEveryLogin should call provisionUser on every sign samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", wantAssertionsSigned: false, signatureAlgorithm: "sha256", digestAlgorithm: "sha256", @@ -5867,7 +5784,7 @@ describe("SAML provisionUserOnEveryLogin should call provisionUser on every sign }); const samlRedirectUrl1 = new URL(response1.response?.url); - await auth.api.callbackSSOSAML({ + await auth.api.acsEndpoint({ method: "POST", body: { SAMLResponse: samlResponse.samlResponse, @@ -5903,7 +5820,7 @@ describe("SAML provisionUserOnEveryLogin should call provisionUser on every sign }); const samlRedirectUrl2 = new URL(response2.response?.url); - await auth.api.callbackSSOSAML({ + await auth.api.acsEndpoint({ method: "POST", body: { SAMLResponse: samlResponse2.samlResponse, @@ -5928,20 +5845,16 @@ describe("SAML provisionUserOnEveryLogin should call provisionUser on every sign */ describe("SAML SSO Hardening", () => { /** - * RFC: SAML 2.0 Core §2.3.3 - ACS URL in SP metadata MUST match the ACS URL - * in AuthnRequests. When callbackUrl is omitted, both should derive the same - * ACS from baseURL + providerId. + * SAML 2.0 Core §2.3.3: ACS URL in SP metadata MUST match the ACS URL + * in AuthnRequests. Both derive from baseURL + providerId. */ describe("ACS URL consistency (provider.id vs providerId)", () => { - // When callbackUrl is split from ACS URL, SP metadata should - // always derive the ACS from baseURL + providerId regardless of callbackUrl. - it.todo("should use providerId (not internal row ID) in SP metadata ACS URL when callbackUrl is an app destination", async () => { + it("should use providerId (not internal row ID) in SP metadata ACS URL", async () => { const { auth, signInWithTestUser } = await getTestInstance({ plugins: [sso()], }); const { headers } = await signInWithTestUser(); - // Register with callbackUrl pointing to app destination (not an ACS route). // When spMetadata.metadata is absent, the SP metadata endpoint should // generate an ACS URL from baseURL + providerId, not from provider.id (row UUID). await auth.api.registerSSOProvider({ @@ -5952,7 +5865,6 @@ describe("SAML SSO Hardening", () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", spMetadata: {}, }, }, @@ -5964,15 +5876,11 @@ describe("SAML SSO Hardening", () => { }); const xml = await spMetadataRes.text(); - // When callbackUrl is an app destination (not an ACS URL), the SP metadata - // generator should still use a proper ACS URL with the providerId. // The generated metadata should contain the providerId, not the row UUID. expect(xml).toContain("acs-consistency-test"); }); - // When callbackUrl is split from ACS URL, both SP metadata - // and AuthnRequest should derive ACS from the same source. - it.todo("should produce matching ACS URLs in SP metadata and AuthnRequest", async () => { + it("should produce matching ACS URLs in SP metadata and AuthnRequest", async () => { const { auth, signInWithTestUser } = await getTestInstance({ plugins: [sso()], }); @@ -5986,7 +5894,6 @@ describe("SAML SSO Hardening", () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", idpMetadata: { metadata: idpMetadata, }, @@ -6038,7 +5945,6 @@ describe("SAML SSO Hardening", () => { issuer: "http://localhost:8081", entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", spMetadata: { metadata: spMetadata }, }, }, @@ -6057,7 +5963,6 @@ describe("SAML SSO Hardening", () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", spMetadata: { metadata: spMetadata }, idpMetadata: { metadata: idpMetadata }, }, @@ -6100,7 +6005,6 @@ describe("SAML SSO Hardening", () => { issuer: "http://localhost:8081", entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/dashboard", spMetadata: { metadata: spMetadata }, }, }, @@ -6154,7 +6058,6 @@ describe("SAML SSO Hardening", () => { samlConfig: { entryPoint: "not-a-url", cert: "placeholder", - callbackUrl: "http://localhost:3000/dashboard", spMetadata: {}, }, }, @@ -6171,10 +6074,10 @@ describe("SAML SSO Hardening", () => { /** * SAML 2.0 Bindings §3.5.3 - RelayState is an opaque reference to state * maintained at the SP. The SP MUST use it to determine where to redirect - * the user after authentication. Config-level callbackUrl is a fallback. + * the user after authentication. */ - describe("RelayState priority over callbackUrl", () => { - it("should redirect to RelayState callbackURL, not config callbackUrl", async () => { + describe("RelayState controls post-auth redirect", () => { + it("should redirect to RelayState callbackURL after authentication", async () => { const { auth, signInWithTestUser } = await getTestInstance({ plugins: [sso()], }); @@ -6188,7 +6091,6 @@ describe("SAML SSO Hardening", () => { samlConfig: { entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", cert: certificate, - callbackUrl: "http://localhost:3000/from-config", idpMetadata: { metadata: idpMetadata }, spMetadata: { metadata: spMetadata }, }, @@ -6217,7 +6119,7 @@ describe("SAML SSO Hardening", () => { const relayState = signInUrl.searchParams.get("RelayState") ?? ""; // POST to callback with RelayState - const callbackResponse = (await auth.api.callbackSSOSAML({ + const callbackResponse = (await auth.api.acsEndpoint({ method: "POST", body: { SAMLResponse: samlResponse.samlResponse, @@ -6229,7 +6131,7 @@ describe("SAML SSO Hardening", () => { const location = callbackResponse.headers.get("location") || ""; - // MUST redirect to the RelayState callbackURL, not config's callbackUrl + // MUST redirect to the RelayState callbackURL expect(location).toContain("/from-relay-state"); expect(location).not.toContain("/from-config"); }); diff --git a/packages/sso/src/saml/response-validation.ts b/packages/sso/src/saml/response-validation.ts index 9555b1debd..8f84f7d5d2 100644 --- a/packages/sso/src/saml/response-validation.ts +++ b/packages/sso/src/saml/response-validation.ts @@ -1,6 +1,6 @@ import type { GenericEndpointContext } from "@better-auth/core"; import { AUTHN_REQUEST_KEY_PREFIX } from "../constants"; -import type { SAMLAssertionExtract } from "../types"; +import type { AuthnRequestRecord, SAMLAssertionExtract } from "../types"; function errorRedirectUrl( base: string, @@ -13,8 +13,6 @@ function errorRedirectUrl( url.searchParams.set("error_description", description); return url.toString(); } catch { - // Relative URL — fall back to manual construction. - // Split off any fragment so query params stay before the hash. const hashIdx = base.indexOf("#"); const path = hashIdx >= 0 ? base.slice(0, hashIdx) : base; const hash = hashIdx >= 0 ? base.slice(hashIdx + 1) : undefined; @@ -24,13 +22,6 @@ function errorRedirectUrl( } } -interface AuthnRequestRecord { - id: string; - providerId: string; - createdAt: number; - expiresAt: number; -} - export interface InResponseToValidationContext { extract: SAMLAssertionExtract; providerId: string; @@ -154,6 +145,10 @@ export function validateAudience( ctx: AudienceValidationContext, ): void { if (!ctx.expectedAudience) { + c.context.logger.warn( + "Could not determine SP entity ID for audience validation; skipping", + { providerId: ctx.providerId }, + ); return; } diff --git a/packages/sso/src/types.ts b/packages/sso/src/types.ts index ca9fec1e72..1a0bb0aee5 100644 --- a/packages/sso/src/types.ts +++ b/packages/sso/src/types.ts @@ -44,17 +44,30 @@ export interface OIDCConfig { } export interface SAMLConfig { + /** + * SP Entity ID. Used as the `entityID` in SP metadata when + * `spMetadata.entityID` is not set. Also used as the expected + * audience for SAML assertion validation when `audience` is not set. + */ issuer: string; + /** + * IdP SSO URL. Used as the redirect destination when + * `idpMetadata.metadata` is not provided. Ignored when + * IdP metadata XML is set (the SSO URL is extracted from the XML). + */ entryPoint: string; + /** + * IdP signing certificate. Used to verify SAML response signatures + * when `idpMetadata.metadata` is not provided. Ignored when IdP + * metadata XML is set (the certificate is extracted from the XML). + * When both this and `idpMetadata.cert` are set, `idpMetadata.cert` takes precedence. + */ cert: string; - callbackUrl: string; audience?: string | undefined; idpMetadata?: | { metadata?: string; entityID?: string; - entityURL?: string; - redirectURL?: string; cert?: string; privateKey?: string; privateKeyPass?: string; @@ -71,7 +84,12 @@ export interface SAMLConfig { }>; } | undefined; - spMetadata: { + /** + * SP metadata configuration. All fields are optional; when omitted, + * SP metadata is auto-generated from `issuer`, `wantAssertionsSigned`, + * `authnRequestsSigned`, and `identifierFormat`. + */ + spMetadata?: { metadata?: string | undefined; entityID?: string | undefined; binding?: string | undefined; @@ -81,14 +99,17 @@ export interface SAMLConfig { encPrivateKey?: string | undefined; encPrivateKeyPass?: string | undefined; }; + /** + * Request signed assertions from the IdP. When true, the SP metadata + * advertises `WantAssertionsSigned="true"` and samlify will reject + * unsigned assertions. + */ wantAssertionsSigned?: boolean | undefined; authnRequestsSigned?: boolean | undefined; signatureAlgorithm?: string | undefined; digestAlgorithm?: string | undefined; identifierFormat?: string | undefined; privateKey?: string | undefined; - decryptionPvk?: string | undefined; - additionalParams?: Record | undefined; mapping?: SAMLMapping | undefined; }