refactor(sso)!: remove callbackUrl, consolidate ACS endpoint, fix SLO (#9117)

This commit is contained in:
Gustavo Valverde
2026-04-11 10:03:56 +00:00
committed by GitHub
parent c8cf0f7c1a
commit b70f025bfa
14 changed files with 494 additions and 720 deletions
+31
View File
@@ -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).
@@ -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
<Callout type="info">
**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.
</Callout>
### 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
+190 -197
View File
@@ -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
<Tab value="next-js-app-router">
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: "<!-- Your SP Metadata XML -->",
@@ -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.
<Callout type="info">
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
</Callout>
<Callout type="info">
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.
</Callout>
<Callout type="info">
@@ -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
<DatabaseTable
fields={[
{
name: "id", type: "string", description: "A database identifier", isPrimaryKey: true,
},
{ name: "issuer", type: "string", description: "The issuer identifier" },
{ name: "domain", type: "string", description: "The domain of the provider" },
{ name: "oidcConfig", type: "string", description: "The OIDC configuration (JSON string)", isOptional: true },
{ name: "samlConfig", type: "string", description: "The SAML configuration (JSON string)", isOptional: true },
{ name: "userId", type: "string", description: "The user ID", isForeignKey: true },
{ name: "providerId", type: "string", description: "The provider ID. Used to identify a provider and to generate a redirect URL.", isUnique: true },
{ name: "organizationId", type: "string", description: "The organization Id. If provider is linked to an organization.", isOptional: true }
{
name: "id", type: "string", description: "A database identifier", isPrimaryKey: true,
},
{ name: "issuer", type: "string", description: "The issuer identifier" },
{ name: "domain", type: "string", description: "The domain of the provider" },
{ name: "oidcConfig", type: "string", description: "The OIDC configuration (JSON string)", isOptional: true },
{ name: "samlConfig", type: "string", description: "The SAML configuration (JSON string)", isOptional: true },
{ name: "userId", type: "string", description: "The user ID", isForeignKey: true },
{ name: "providerId", type: "string", description: "The provider ID. Used to identify a provider and to generate a redirect URL.", isUnique: true },
{ name: "organizationId", type: "string", description: "The organization Id. If provider is linked to an organization.", isOptional: true }
]}
/>
@@ -1516,7 +1509,7 @@ The `ssoProvider` schema is extended as follows:
<DatabaseTable
fields={[
{ name: "domainVerified", type: "boolean", description: "A flag indicating whether the provider domain has been verified.", isOptional: true },
{ name: "domainVerified", type: "boolean", description: "A flag indicating whether the provider domain has been verified.", isOptional: true },
]}
/>
@@ -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
<TypeTable
type={{
provisionUser: {
description: "A custom function to provision a user when they sign in with an SSO provider.",
type: "function",
description: "A custom function to provision a user when they sign in with an SSO provider.",
type: "function",
},
provisionUserOnEveryLogin: {
description: "If true, the provisionUser callback will be called on every login, not just when a new user is registered.",
type: "boolean",
default: false,
description: "If true, the provisionUser callback will be called on every login, not just when a new user is registered.",
type: "boolean",
default: false,
},
organizationProvisioning: {
description: "Options for provisioning users to an organization.",
type: "object",
properties: {
disabled: {
description: "Disable organization provisioning.",
type: "boolean",
default: false,
},
defaultRole: {
description: "The default role for new users.",
type: "string",
enum: ["member", "admin"],
default: "member",
},
getRole: {
description: "A custom function to determine the role for new users.",
type: "function",
},
},
description: "Options for provisioning users to an organization.",
type: "object",
properties: {
disabled: {
description: "Disable organization provisioning.",
type: "boolean",
default: false,
},
defaultRole: {
description: "The default role for new users.",
type: "string",
enum: ["member", "admin"],
default: "member",
},
getRole: {
description: "A custom function to determine the role for new users.",
type: "function",
},
},
},
defaultOverrideUserInfo: {
description: "Override user info with the provider info by default.",
type: "boolean",
default: false,
description: "Override user info with the provider info by default.",
type: "boolean",
default: false,
},
disableImplicitSignUp: {
description: "Disable implicit sign up for new users. When set to true, sign-in needs to be called with requestSignUp as true to create new users.",
type: "boolean",
default: false,
description: "Disable implicit sign up for new users. When set to true, sign-in needs to be called with requestSignUp as true to create new users.",
type: "boolean",
default: false,
},
providersLimit: {
description: "Configure the maximum number of SSO providers a user can register. Set to 0 to disable SSO provider registration.",
type: "number | function",
default: 10,
description: "Configure the maximum number of SSO providers a user can register. Set to 0 to disable SSO provider registration.",
type: "number | function",
default: 10,
},
redirectURI: {
description: "Custom redirect URI for OIDC SSO callbacks. When set, all OIDC providers share this single callback URL instead of per-provider URLs. The provider ID is stored in the OAuth state. Can be a relative path (e.g., '/sso/callback') or a full URL.",
type: "string",
required: false,
description: "Custom redirect URI for OIDC SSO callbacks. When set, all OIDC providers share this single callback URL instead of per-provider URLs. The provider ID is stored in the OAuth state. Can be a relative path (e.g., '/sso/callback') or a full URL.",
type: "string",
required: false,
},
domainVerification: {
description: "Configure the domain verification feature",
type: "object",
properties: {
enabled: {
description: "Enables or disables the domain verification feature",
type: "boolean",
required: false
},
tokenPrefix: {
description: "Prefix used to generate the domain verification identifier. An underscore is automatically prepended.",
type: "string",
required: false,
default: "better-auth-token"
},
},
description: "Configure the domain verification feature",
type: "object",
properties: {
enabled: {
description: "Enables or disables the domain verification feature",
type: "boolean",
required: false
},
tokenPrefix: {
description: "Prefix used to generate the domain verification identifier. An underscore is automatically prepended.",
type: "string",
required: false,
default: "better-auth-token"
},
},
},
defaultSSO: {
description: "Configure a default SSO provider for testing and development. This provider will be used when no matching provider is found in the database.",
type: "array",
items: {
type: "object",
properties: {
domain: {
description: "The domain to match for this default provider.",
type: "string",
required: true,
},
providerId: {
description: "The provider ID to use for the default provider.",
type: "string",
required: true,
},
samlConfig: {
description: "SAML configuration for the default provider.",
type: "SAMLConfig",
required: false,
},
oidcConfig: {
description: "OIDC configuration for the default provider.",
type: "OIDCConfig",
required: false,
},
}
},
description: "Configure a default SSO provider for testing and development. This provider will be used when no matching provider is found in the database.",
type: "array",
items: {
type: "object",
properties: {
domain: {
description: "The domain to match for this default provider.",
type: "string",
required: true,
},
providerId: {
description: "The provider ID to use for the default provider.",
type: "string",
required: true,
},
samlConfig: {
description: "SAML configuration for the default provider.",
type: "SAMLConfig",
required: false,
},
oidcConfig: {
description: "OIDC configuration for the default provider.",
type: "OIDCConfig",
required: false,
},
}
},
},
saml: {
description: "SAML security options for AuthnRequest/InResponseTo validation, replay protection, and timestamp handling.",
type: "object",
properties: {
enableInResponseToValidation: {
description: "Enable InResponseTo validation for SP-initiated SAML flows.",
type: "boolean",
default: true,
},
allowIdpInitiated: {
description: "Allow IdP-initiated SSO (unsolicited SAML responses). Set to false for stricter security. Only applies when validation is enabled.",
type: "boolean",
default: true,
},
requestTTL: {
description: "TTL for AuthnRequest records in milliseconds. Only applies when validation is enabled.",
type: "number",
default: 300000,
},
clockSkew: {
description: "Clock skew tolerance for SAML assertion timestamp validation (NotBefore/NotOnOrAfter) in milliseconds. Allows for minor time differences between IdP and SP servers.",
type: "number",
default: 300000,
},
requireTimestamps: {
description: "Require timestamp conditions (NotBefore/NotOnOrAfter) in SAML assertions. When enabled, assertions without timestamps are rejected. When disabled, they are accepted with a warning logged.",
type: "boolean",
default: false,
},
algorithms: {
description: "Algorithm validation options.",
type: "object",
properties: {
onDeprecated: {
description: "Behavior for deprecated algorithms (SHA-1, RSA 1.5, 3DES).",
type: "string",
enum: ["reject", "warn", "allow"],
default: "warn",
},
},
},
maxResponseSize: {
description: "Maximum allowed size for SAML responses in bytes.",
type: "number",
default: 262144,
},
maxMetadataSize: {
description: "Maximum allowed size for IdP metadata XML in bytes.",
type: "number",
default: 102400,
},
},
description: "SAML security options for AuthnRequest/InResponseTo validation, replay protection, and timestamp handling.",
type: "object",
properties: {
enableInResponseToValidation: {
description: "Enable InResponseTo validation for SP-initiated SAML flows.",
type: "boolean",
default: true,
},
allowIdpInitiated: {
description: "Allow IdP-initiated SSO (unsolicited SAML responses). Set to false for stricter security. Only applies when validation is enabled.",
type: "boolean",
default: true,
},
requestTTL: {
description: "TTL for AuthnRequest records in milliseconds. Only applies when validation is enabled.",
type: "number",
default: 300000,
},
clockSkew: {
description: "Clock skew tolerance for SAML assertion timestamp validation (NotBefore/NotOnOrAfter) in milliseconds. Allows for minor time differences between IdP and SP servers.",
type: "number",
default: 300000,
},
requireTimestamps: {
description: "Require timestamp conditions (NotBefore/NotOnOrAfter) in SAML assertions. When enabled, assertions without timestamps are rejected. When disabled, they are accepted with a warning logged.",
type: "boolean",
default: false,
},
algorithms: {
description: "Algorithm validation options.",
type: "object",
properties: {
onDeprecated: {
description: "Behavior for deprecated algorithms (SHA-1, RSA 1.5, 3DES).",
type: "string",
enum: ["reject", "warn", "allow"],
default: "warn",
},
},
},
maxResponseSize: {
description: "Maximum allowed size for SAML responses in bytes.",
type: "number",
default: 262144,
},
maxMetadataSize: {
description: "Maximum allowed size for IdP metadata XML in bytes.",
type: "number",
default: 102400,
},
},
},
modelName: {
description: "The model name for the SSO provider table",
type: "string",
default: "ssoProvider"
description: "The model name for the SSO provider table",
type: "string",
default: "ssoProvider"
},
fields: {
issuer: {
description: "Custom name for the issuer column",
type: "string",
default: "issuer",
},
oidcConfig: {
description: "Custom name for the oidcConfig column",
type: "string",
default: "oidcConfig",
},
samlConfig: {
description: "Custom name for the samlConfig column",
type: "string",
default: "samlConfig",
},
userId: {
description: "Custom name for the userId column",
type: "string",
default: "userId",
},
providerId: {
description: "Custom name for the providerId column",
type: "string",
default: "providerId",
},
organizationId: {
description: "Custom name for the organizationId column",
type: "string",
default: "organizationId",
},
domain: {
description: "Custom name for the domain column",
type: "string",
default: "domain",
}
issuer: {
description: "Custom name for the issuer column",
type: "string",
default: "issuer",
},
oidcConfig: {
description: "Custom name for the oidcConfig column",
type: "string",
default: "oidcConfig",
},
samlConfig: {
description: "Custom name for the samlConfig column",
type: "string",
default: "samlConfig",
},
userId: {
description: "Custom name for the userId column",
type: "string",
default: "userId",
},
providerId: {
description: "Custom name for the providerId column",
type: "string",
default: "providerId",
},
organizationId: {
description: "Custom name for the organizationId column",
type: "string",
default: "organizationId",
},
domain: {
description: "Custom name for the domain column",
type: "string",
default: "domain",
}
}
}}
/>
@@ -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: {},
},
},
+2 -6
View File
@@ -17,7 +17,6 @@ import {
import {
acsEndpoint,
callbackSSO,
callbackSSOSAML,
callbackSSOShared,
initiateSLO,
registerSSOProvider,
@@ -100,7 +99,6 @@ type SSOEndpoints<O extends SSOOptions> = {
signInSSO: ReturnType<typeof signInSSO>;
callbackSSO: ReturnType<typeof callbackSSO>;
callbackSSOShared: ReturnType<typeof callbackSSOShared>;
callbackSSOSAML: ReturnType<typeof callbackSSOSAML>;
acsEndpoint: ReturnType<typeof acsEndpoint>;
sloEndpoint: ReturnType<typeof sloEndpoint>;
initiateSLO: ReturnType<typeof initiateSLO>;
@@ -125,9 +123,8 @@ export type SSOPlugin<O extends SSOOptions> = {
* 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<O extends SSOOptions>(
signInSSO: signInSSO(optionsWithStore),
callbackSSO: callbackSSO(optionsWithStore),
callbackSSOShared: callbackSSOShared(optionsWithStore),
callbackSSOSAML: callbackSSOSAML(optionsWithStore),
acsEndpoint: acsEndpoint(optionsWithStore),
sloEndpoint: sloEndpoint(optionsWithStore),
initiateSLO: initiateSLO(optionsWithStore),
-7
View File
@@ -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: {},
},
},
+38 -29
View File
@@ -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,
});
}
-2
View File
@@ -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,
+11 -14
View File
@@ -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 }) =>
-3
View File
@@ -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,
});
+76 -230
View File
@@ -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 = <O extends SSOOptions>(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 = <O extends SSOOptions>(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);
File diff suppressed because it is too large Load Diff
+5 -10
View File
@@ -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;
}
+27 -6
View File
@@ -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<string, any> | undefined;
mapping?: SAMLMapping | undefined;
}