mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-26 02:16:23 -05:00
refactor(sso)!: remove callbackUrl, consolidate ACS endpoint, fix SLO (#9117)
This commit is contained in:
@@ -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
@@ -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: {},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
+111
-209
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user