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;
}