From 50ffecbfadccdbc58a0888f48e31c6c02ca79130 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Paola=20Estefan=C3=ADa=20de=20Campos?=
<84341268+Paola3stefania@users.noreply.github.com>
Date: Thu, 11 Dec 2025 20:57:00 -0300
Subject: [PATCH] feat(sso): add InResponseTo validation (#6557)
---
docs/content/docs/plugins/sso.mdx | 135 ++++++++++
packages/sso/src/authn-request-store.ts | 76 ++++++
packages/sso/src/authn-request.test.ts | 99 +++++++
packages/sso/src/index.ts | 26 +-
packages/sso/src/routes/sso.ts | 209 ++++++++++++++-
packages/sso/src/saml.test.ts | 337 +++++++++++++++++++++++-
packages/sso/src/types.ts | 49 ++++
7 files changed, 922 insertions(+), 9 deletions(-)
create mode 100644 packages/sso/src/authn-request-store.ts
create mode 100644 packages/sso/src/authn-request.test.ts
diff --git a/docs/content/docs/plugins/sso.mdx b/docs/content/docs/plugins/sso.mdx
index 1dc1aa3fca..9cac91eb58 100644
--- a/docs/content/docs/plugins/sso.mdx
+++ b/docs/content/docs/plugins/sso.mdx
@@ -707,6 +707,115 @@ mapping: {
}
```
+## SAML Security
+
+The SSO plugin includes optional security features to protect against common SAML vulnerabilities.
+
+### AuthnRequest / InResponseTo Validation
+
+You can enable InResponseTo validation for SP-initiated SAML flows. When enabled, the plugin tracks AuthnRequest IDs and validates the `InResponseTo` attribute in SAML responses. This prevents:
+
+- **Unsolicited responses**: Responses not triggered by a legitimate login request
+- **Replay attacks**: Reusing old SAML responses
+- **Cross-provider injection**: Responses meant for a different provider
+
+
+This feature is **opt-in** to ensure backward compatibility. Enable it explicitly for enhanced security.
+
+
+#### Enabling Validation (Single Instance)
+
+For single-instance deployments, enable validation with the built-in in-memory store:
+
+```ts title="auth.ts"
+import { betterAuth } from "better-auth";
+import { sso } from "@better-auth/sso";
+
+const auth = betterAuth({
+ plugins: [
+ sso({
+ saml: {
+ // Enable InResponseTo validation
+ enableInResponseToValidation: true,
+ // Optionally reject IdP-initiated SSO (stricter security)
+ allowIdpInitiated: false,
+ // Custom TTL for AuthnRequest validity (default: 5 minutes)
+ requestTTL: 10 * 60 * 1000, // 10 minutes
+ },
+ }),
+ ],
+});
+```
+
+#### Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `enableInResponseToValidation` | `boolean` | `false` | Enable InResponseTo validation for SP-initiated flows. |
+| `allowIdpInitiated` | `boolean` | `true` | Allow IdP-initiated SSO (responses without InResponseTo). Set to `false` for stricter security. Only applies when validation is enabled. |
+| `requestTTL` | `number` | `300000` (5 min) | Time-to-live for AuthnRequest records in milliseconds. Requests older than this will be rejected. |
+| `authnRequestStore` | `AuthnRequestStore` | In-memory | Custom store implementation. Providing a custom store automatically enables validation. |
+
+#### Multi-Instance Deployments (Production)
+
+
+For multi-instance deployments (load-balanced servers, serverless, etc.), you **must** provide a shared store like Redis. The default in-memory store only works for single-instance deployments.
+
+
+Providing a custom `authnRequestStore` automatically enables InResponseTo validation:
+
+```ts title="auth.ts"
+import { betterAuth } from "better-auth";
+import { sso, type AuthnRequestStore } from "@better-auth/sso";
+
+// Example Redis-backed store
+const redisAuthnRequestStore: AuthnRequestStore = {
+ async save(record) {
+ const ttl = Math.ceil((record.expiresAt - Date.now()) / 1000);
+ await redis.set(
+ `authn:${record.id}`,
+ JSON.stringify(record),
+ "EX",
+ ttl
+ );
+ },
+ async get(id) {
+ const data = await redis.get(`authn:${id}`);
+ if (!data) return null;
+ const record = JSON.parse(data);
+ if (record.expiresAt < Date.now()) {
+ await redis.del(`authn:${id}`);
+ return null;
+ }
+ return record;
+ },
+ async delete(id) {
+ await redis.del(`authn:${id}`);
+ },
+};
+
+const auth = betterAuth({
+ plugins: [
+ sso({
+ saml: {
+ // Providing a store automatically enables validation
+ authnRequestStore: redisAuthnRequestStore,
+ // Optionally configure other options
+ allowIdpInitiated: false,
+ },
+ }),
+ ],
+});
+```
+
+#### Error Handling
+
+When InResponseTo validation fails, users are redirected with an error query parameter:
+
+- `?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID` — The request ID was not found or has expired
+- `?error=invalid_saml_response&error_description=Provider+mismatch` — The response was meant for a different provider
+- `?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed` — IdP-initiated SSO is disabled
+
## Domain verification
Domain verification allows your application to automatically trust a new SSO provider
@@ -965,6 +1074,32 @@ If you want to allow account linking for specific trusted providers, enable the
}
},
},
+ saml: {
+ description: "SAML security options for AuthnRequest/InResponseTo validation.",
+ type: "object",
+ properties: {
+ enableInResponseToValidation: {
+ description: "Enable InResponseTo validation for SP-initiated SAML flows. Opt-in for backward compatibility.",
+ type: "boolean",
+ default: false,
+ },
+ 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,
+ },
+ authnRequestStore: {
+ description: "Custom AuthnRequest store for multi-instance deployments (e.g., Redis). Providing a store automatically enables validation.",
+ type: "AuthnRequestStore",
+ required: false,
+ },
+ },
+ },
modelName: {
description: "The model name for the SSO provider table",
type: "string",
diff --git a/packages/sso/src/authn-request-store.ts b/packages/sso/src/authn-request-store.ts
new file mode 100644
index 0000000000..945c18fae9
--- /dev/null
+++ b/packages/sso/src/authn-request-store.ts
@@ -0,0 +1,76 @@
+/**
+ * AuthnRequest Store
+ *
+ * Tracks SAML AuthnRequest IDs to enable InResponseTo validation.
+ * This prevents:
+ * - Unsolicited SAML responses
+ * - Cross-provider response injection
+ * - Replay attacks
+ * - Expired login completions
+ */
+
+export interface AuthnRequestRecord {
+ id: string;
+ providerId: string;
+ createdAt: number;
+ expiresAt: number;
+}
+
+export interface AuthnRequestStore {
+ save(record: AuthnRequestRecord): Promise;
+ get(id: string): Promise;
+ delete(id: string): Promise;
+}
+
+/**
+ * Default TTL for AuthnRequest records (5 minutes).
+ * This should be sufficient for most IdPs while protecting against stale requests.
+ */
+export const DEFAULT_AUTHN_REQUEST_TTL_MS = 5 * 60 * 1000;
+
+/**
+ * In-memory implementation of AuthnRequestStore.
+ * ⚠️ Only suitable for testing or single-instance non-serverless deployments.
+ * For production, rely on the default behavior (uses verification table)
+ * or provide a custom Redis-backed store.
+ */
+export function createInMemoryAuthnRequestStore(): AuthnRequestStore {
+ const store = new Map();
+
+ const cleanup = () => {
+ const now = Date.now();
+ for (const [id, record] of store.entries()) {
+ if (record.expiresAt < now) {
+ store.delete(id);
+ }
+ }
+ };
+
+ const cleanupInterval = setInterval(cleanup, 60 * 1000);
+
+ if (typeof cleanupInterval.unref === "function") {
+ cleanupInterval.unref();
+ }
+
+ return {
+ async save(record: AuthnRequestRecord): Promise {
+ store.set(record.id, record);
+ },
+
+ async get(id: string): Promise {
+ const record = store.get(id);
+ if (!record) {
+ return null;
+ }
+ if (record.expiresAt < Date.now()) {
+ store.delete(id);
+ return null;
+ }
+ return record;
+ },
+
+ async delete(id: string): Promise {
+ store.delete(id);
+ },
+ };
+}
diff --git a/packages/sso/src/authn-request.test.ts b/packages/sso/src/authn-request.test.ts
new file mode 100644
index 0000000000..0607284481
--- /dev/null
+++ b/packages/sso/src/authn-request.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from "vitest";
+import {
+ createInMemoryAuthnRequestStore,
+ DEFAULT_AUTHN_REQUEST_TTL_MS,
+} from "./authn-request-store";
+
+describe("AuthnRequest Store", () => {
+ describe("In-Memory Store", () => {
+ it("should save and retrieve an AuthnRequest record", async () => {
+ const store = createInMemoryAuthnRequestStore();
+
+ const record = {
+ id: "_test-request-id-1",
+ providerId: "saml-provider-1",
+ createdAt: Date.now(),
+ expiresAt: Date.now() + DEFAULT_AUTHN_REQUEST_TTL_MS,
+ };
+
+ await store.save(record);
+ const retrieved = await store.get(record.id);
+
+ expect(retrieved).toEqual(record);
+ });
+
+ it("should return null for non-existent request ID", async () => {
+ const store = createInMemoryAuthnRequestStore();
+
+ const retrieved = await store.get("_non-existent-id");
+
+ expect(retrieved).toBeNull();
+ });
+
+ it("should return null for expired request ID", async () => {
+ const store = createInMemoryAuthnRequestStore();
+
+ const record = {
+ id: "_expired-request-id",
+ providerId: "saml-provider-1",
+ createdAt: Date.now() - 10000,
+ expiresAt: Date.now() - 1000, // Already expired
+ };
+
+ await store.save(record);
+ const retrieved = await store.get(record.id);
+
+ expect(retrieved).toBeNull();
+ });
+
+ it("should delete a request ID", async () => {
+ const store = createInMemoryAuthnRequestStore();
+
+ const record = {
+ id: "_delete-me",
+ providerId: "saml-provider-1",
+ createdAt: Date.now(),
+ expiresAt: Date.now() + DEFAULT_AUTHN_REQUEST_TTL_MS,
+ };
+
+ await store.save(record);
+ await store.delete(record.id);
+
+ const retrieved = await store.get(record.id);
+ expect(retrieved).toBeNull();
+ });
+
+ it("should handle multiple providers with different request IDs", async () => {
+ const store = createInMemoryAuthnRequestStore();
+
+ const record1 = {
+ id: "_request-provider-1",
+ providerId: "saml-provider-1",
+ createdAt: Date.now(),
+ expiresAt: Date.now() + DEFAULT_AUTHN_REQUEST_TTL_MS,
+ };
+
+ const record2 = {
+ id: "_request-provider-2",
+ providerId: "saml-provider-2",
+ createdAt: Date.now(),
+ expiresAt: Date.now() + DEFAULT_AUTHN_REQUEST_TTL_MS,
+ };
+
+ await store.save(record1);
+ await store.save(record2);
+
+ const retrieved1 = await store.get(record1.id);
+ const retrieved2 = await store.get(record2.id);
+
+ expect(retrieved1?.providerId).toBe("saml-provider-1");
+ expect(retrieved2?.providerId).toBe("saml-provider-2");
+ });
+ });
+
+ describe("DEFAULT_AUTHN_REQUEST_TTL_MS", () => {
+ it("should be 5 minutes in milliseconds", () => {
+ expect(DEFAULT_AUTHN_REQUEST_TTL_MS).toBe(5 * 60 * 1000);
+ });
+ });
+});
diff --git a/packages/sso/src/index.ts b/packages/sso/src/index.ts
index 5e45274d93..3ae4b2c599 100644
--- a/packages/sso/src/index.ts
+++ b/packages/sso/src/index.ts
@@ -1,6 +1,14 @@
import type { BetterAuthPlugin } from "better-auth";
import { XMLValidator } from "fast-xml-parser";
import * as saml from "samlify";
+import type {
+ AuthnRequestRecord,
+ AuthnRequestStore,
+} from "./authn-request-store";
+import {
+ createInMemoryAuthnRequestStore,
+ DEFAULT_AUTHN_REQUEST_TTL_MS,
+} from "./authn-request-store";
import {
requestDomainVerification,
verifyDomain,
@@ -16,6 +24,8 @@ import {
import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "./types";
export type { SAMLConfig, OIDCConfig, SSOOptions, SSOProvider };
+export type { AuthnRequestStore, AuthnRequestRecord };
+export { createInMemoryAuthnRequestStore, DEFAULT_AUTHN_REQUEST_TTL_MS };
const fastValidator = {
async validate(xml: string) {
@@ -71,19 +81,21 @@ export function sso(
};
export function sso(options?: O | undefined): any {
+ const optionsWithStore = options as O;
+
let endpoints = {
spMetadata: spMetadata(),
- registerSSOProvider: registerSSOProvider(options as O),
- signInSSO: signInSSO(options as O),
- callbackSSO: callbackSSO(options as O),
- callbackSSOSAML: callbackSSOSAML(options as O),
- acsEndpoint: acsEndpoint(options as O),
+ registerSSOProvider: registerSSOProvider(optionsWithStore),
+ signInSSO: signInSSO(optionsWithStore),
+ callbackSSO: callbackSSO(optionsWithStore),
+ callbackSSOSAML: callbackSSOSAML(optionsWithStore),
+ acsEndpoint: acsEndpoint(optionsWithStore),
};
if (options?.domainVerification?.enabled) {
const domainVerificationEndpoints = {
- requestDomainVerification: requestDomainVerification(options as O),
- verifyDomain: verifyDomain(options as O),
+ requestDomainVerification: requestDomainVerification(optionsWithStore),
+ verifyDomain: verifyDomain(optionsWithStore),
};
endpoints = {
diff --git a/packages/sso/src/routes/sso.ts b/packages/sso/src/routes/sso.ts
index b4b854ed5e..ad3caa81c4 100644
--- a/packages/sso/src/routes/sso.ts
+++ b/packages/sso/src/routes/sso.ts
@@ -22,9 +22,13 @@ import type { BindingContext } from "samlify/types/src/entity";
import type { IdentityProvider } from "samlify/types/src/entity-idp";
import type { FlowResult } from "samlify/types/src/flow";
import * as z from "zod/v4";
+import type { AuthnRequestRecord } from "../authn-request-store";
+import { DEFAULT_AUTHN_REQUEST_TTL_MS } from "../authn-request-store";
import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
+
import { safeJsonParse, validateEmailDomain } from "../utils";
+const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
const spMetadataQuerySchema = z.object({
providerId: z.string(),
format: z.enum(["xml", "json"]).default("xml"),
@@ -1055,12 +1059,40 @@ export const signInSSO = (options?: SSOOptions) => {
const loginRequest = sp.createLoginRequest(
idp,
"redirect",
- ) as BindingContext & { entityEndpoint: string; type: string };
+ ) as BindingContext & {
+ entityEndpoint: string;
+ type: string;
+ id: string;
+ };
if (!loginRequest) {
throw new APIError("BAD_REQUEST", {
message: "Invalid SAML request",
});
}
+
+ const shouldSaveRequest =
+ loginRequest.id &&
+ (options?.saml?.authnRequestStore ||
+ options?.saml?.enableInResponseToValidation);
+ if (shouldSaveRequest) {
+ const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
+ const record: AuthnRequestRecord = {
+ id: loginRequest.id,
+ providerId: provider.providerId,
+ createdAt: Date.now(),
+ expiresAt: Date.now() + ttl,
+ };
+ if (options?.saml?.authnRequestStore) {
+ await options.saml.authnRequestStore.save(record);
+ } else {
+ await ctx.context.internalAdapter.createVerificationValue({
+ identifier: `${AUTHN_REQUEST_KEY_PREFIX}${record.id}`,
+ value: JSON.stringify(record),
+ expiresAt: new Date(record.expiresAt),
+ });
+ }
+ }
+
return ctx.json({
url: `${loginRequest.context}&RelayState=${encodeURIComponent(
body.callbackURL,
@@ -1647,6 +1679,93 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
}
const { extract } = parsedResponse!;
+
+ const inResponseTo = (extract as any).inResponseTo as string | undefined;
+ const shouldValidateInResponseTo =
+ options?.saml?.authnRequestStore ||
+ options?.saml?.enableInResponseToValidation;
+
+ if (shouldValidateInResponseTo) {
+ const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
+
+ if (inResponseTo) {
+ let storedRequest: AuthnRequestRecord | null = null;
+
+ if (options?.saml?.authnRequestStore) {
+ storedRequest =
+ await options.saml.authnRequestStore.get(inResponseTo);
+ } else {
+ const verification =
+ await ctx.context.internalAdapter.findVerificationValue(
+ `${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
+ );
+ if (verification) {
+ try {
+ storedRequest = JSON.parse(
+ verification.value,
+ ) as AuthnRequestRecord;
+ } catch {
+ storedRequest = null;
+ }
+ }
+ }
+
+ if (!storedRequest) {
+ ctx.context.logger.error(
+ "SAML InResponseTo validation failed: unknown or expired request ID",
+ { inResponseTo, providerId: provider.providerId },
+ );
+ const redirectUrl =
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
+ throw ctx.redirect(
+ `${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
+ );
+ }
+
+ if (storedRequest.providerId !== provider.providerId) {
+ ctx.context.logger.error(
+ "SAML InResponseTo validation failed: provider mismatch",
+ {
+ inResponseTo,
+ expectedProvider: storedRequest.providerId,
+ actualProvider: provider.providerId,
+ },
+ );
+
+ if (options?.saml?.authnRequestStore) {
+ await options.saml.authnRequestStore.delete(inResponseTo);
+ } else {
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(
+ `${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
+ );
+ }
+ const redirectUrl =
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
+ throw ctx.redirect(
+ `${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
+ );
+ }
+
+ if (options?.saml?.authnRequestStore) {
+ await options.saml.authnRequestStore.delete(inResponseTo);
+ } else {
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(
+ `${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
+ );
+ }
+ } else if (!allowIdpInitiated) {
+ ctx.context.logger.error(
+ "SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
+ { providerId: provider.providerId },
+ );
+ const redirectUrl =
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
+ throw ctx.redirect(
+ `${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
+ );
+ }
+ }
+
const attributes = extract.attributes || {};
const mapping = parsedSamlConfig.mapping ?? {};
@@ -2023,6 +2142,94 @@ export const acsEndpoint = (options?: SSOOptions) => {
}
const { extract } = parsedResponse!;
+
+ const inResponseToAcs = (extract as any).inResponseTo as
+ | string
+ | undefined;
+ const shouldValidateInResponseToAcs =
+ options?.saml?.authnRequestStore ||
+ options?.saml?.enableInResponseToValidation;
+
+ if (shouldValidateInResponseToAcs) {
+ const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
+
+ if (inResponseToAcs) {
+ let storedRequest: AuthnRequestRecord | null = null;
+
+ if (options?.saml?.authnRequestStore) {
+ storedRequest =
+ await options.saml.authnRequestStore.get(inResponseToAcs);
+ } else {
+ const verification =
+ await ctx.context.internalAdapter.findVerificationValue(
+ `${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
+ );
+ if (verification) {
+ try {
+ storedRequest = JSON.parse(
+ verification.value,
+ ) as AuthnRequestRecord;
+ } catch {
+ storedRequest = null;
+ }
+ }
+ }
+
+ if (!storedRequest) {
+ ctx.context.logger.error(
+ "SAML InResponseTo validation failed: unknown or expired request ID",
+ { inResponseTo: inResponseToAcs, providerId },
+ );
+ const redirectUrl =
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
+ throw ctx.redirect(
+ `${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
+ );
+ }
+
+ if (storedRequest.providerId !== providerId) {
+ ctx.context.logger.error(
+ "SAML InResponseTo validation failed: provider mismatch",
+ {
+ inResponseTo: inResponseToAcs,
+ expectedProvider: storedRequest.providerId,
+ actualProvider: providerId,
+ },
+ );
+ if (options?.saml?.authnRequestStore) {
+ await options.saml.authnRequestStore.delete(inResponseToAcs);
+ } else {
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(
+ `${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
+ );
+ }
+ const redirectUrl =
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
+ throw ctx.redirect(
+ `${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
+ );
+ }
+
+ if (options?.saml?.authnRequestStore) {
+ await options.saml.authnRequestStore.delete(inResponseToAcs);
+ } else {
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(
+ `${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
+ );
+ }
+ } else if (!allowIdpInitiated) {
+ ctx.context.logger.error(
+ "SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
+ { providerId },
+ );
+ const redirectUrl =
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
+ throw ctx.redirect(
+ `${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
+ );
+ }
+ }
+
const attributes = extract.attributes || {};
const mapping = parsedSamlConfig.mapping ?? {};
diff --git a/packages/sso/src/saml.test.ts b/packages/sso/src/saml.test.ts
index 7e2e70ab07..ffd4cc0b0d 100644
--- a/packages/sso/src/saml.test.ts
+++ b/packages/sso/src/saml.test.ts
@@ -24,7 +24,7 @@ import {
it,
vi,
} from "vitest";
-import { sso } from ".";
+import { createInMemoryAuthnRequestStore, sso } from ".";
import { ssoClient } from "./client";
const spMetadata = `
@@ -1325,6 +1325,341 @@ describe("SAML SSO", async () => {
expect(redirectLocation).not.toContain("error");
expect(redirectLocation).toContain("dashboard");
});
+
+ it("should reject unsolicited SAML response when allowIdpInitiated is false", async () => {
+ const { auth, signInWithTestUser } = await getTestInstance({
+ plugins: [
+ sso({
+ saml: {
+ enableInResponseToValidation: true,
+ allowIdpInitiated: false,
+ },
+ }),
+ ],
+ });
+
+ const { headers } = await signInWithTestUser();
+
+ await auth.api.registerSSOProvider({
+ body: {
+ providerId: "strict-saml-provider",
+ issuer: "http://localhost:8081",
+ domain: "http://localhost:8081",
+ samlConfig: {
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
+ cert: certificate,
+ callbackUrl: "http://localhost:3000/dashboard",
+ wantAssertionsSigned: false,
+ signatureAlgorithm: "sha256",
+ digestAlgorithm: "sha256",
+ idpMetadata: {
+ metadata: idpMetadata,
+ },
+ spMetadata: {
+ metadata: spMetadata,
+ },
+ identifierFormat:
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
+ },
+ },
+ headers,
+ });
+
+ let samlResponse: any;
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
+ onSuccess: async (context) => {
+ samlResponse = await context.data;
+ },
+ });
+
+ const response = await auth.handler(
+ new Request(
+ "http://localhost:3000/api/auth/sso/saml2/callback/strict-saml-provider",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({
+ SAMLResponse: samlResponse.samlResponse,
+ RelayState: "http://localhost:3000/dashboard",
+ }),
+ },
+ ),
+ );
+
+ expect(response.status).toBe(302);
+ const redirectLocation = response.headers.get("location") || "";
+ expect(redirectLocation).toContain("error=unsolicited_response");
+ });
+
+ it("should allow unsolicited SAML response when allowIdpInitiated is true (default)", async () => {
+ const { auth, signInWithTestUser } = await getTestInstance({
+ plugins: [
+ sso({
+ saml: {
+ enableInResponseToValidation: true,
+ allowIdpInitiated: true,
+ },
+ }),
+ ],
+ });
+
+ const { headers } = await signInWithTestUser();
+
+ await auth.api.registerSSOProvider({
+ body: {
+ providerId: "permissive-saml-provider",
+ issuer: "http://localhost:8081",
+ domain: "http://localhost:8081",
+ samlConfig: {
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
+ cert: certificate,
+ callbackUrl: "http://localhost:3000/dashboard",
+ wantAssertionsSigned: false,
+ signatureAlgorithm: "sha256",
+ digestAlgorithm: "sha256",
+ idpMetadata: {
+ metadata: idpMetadata,
+ },
+ spMetadata: {
+ metadata: spMetadata,
+ },
+ identifierFormat:
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
+ },
+ },
+ headers,
+ });
+
+ let samlResponse: any;
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
+ onSuccess: async (context) => {
+ samlResponse = await context.data;
+ },
+ });
+
+ const response = await auth.handler(
+ new Request(
+ "http://localhost:3000/api/auth/sso/saml2/callback/permissive-saml-provider",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({
+ SAMLResponse: samlResponse.samlResponse,
+ RelayState: "http://localhost:3000/dashboard",
+ }),
+ },
+ ),
+ );
+
+ expect(response.status).toBe(302);
+ const redirectLocation = response.headers.get("location") || "";
+ expect(redirectLocation).not.toContain("error=unsolicited_response");
+ });
+
+ it("should skip InResponseTo validation when not explicitly enabled (backward compatibility)", async () => {
+ const { auth, signInWithTestUser } = await getTestInstance({
+ plugins: [sso()],
+ });
+
+ const { headers } = await signInWithTestUser();
+
+ await auth.api.registerSSOProvider({
+ body: {
+ providerId: "legacy-saml-provider",
+ issuer: "http://localhost:8081",
+ domain: "http://localhost:8081",
+ samlConfig: {
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
+ cert: certificate,
+ callbackUrl: "http://localhost:3000/dashboard",
+ wantAssertionsSigned: false,
+ signatureAlgorithm: "sha256",
+ digestAlgorithm: "sha256",
+ idpMetadata: {
+ metadata: idpMetadata,
+ },
+ spMetadata: {
+ metadata: spMetadata,
+ },
+ identifierFormat:
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
+ },
+ },
+ headers,
+ });
+
+ let samlResponse: any;
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
+ onSuccess: async (context) => {
+ samlResponse = await context.data;
+ },
+ });
+
+ const response = await auth.handler(
+ new Request(
+ "http://localhost:3000/api/auth/sso/saml2/callback/legacy-saml-provider",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({
+ SAMLResponse: samlResponse.samlResponse,
+ RelayState: "http://localhost:3000/dashboard",
+ }),
+ },
+ ),
+ );
+
+ expect(response.status).toBe(302);
+ const redirectLocation = response.headers.get("location") || "";
+ expect(redirectLocation).not.toContain("error=");
+ });
+
+ it("should enable validation automatically when custom authnRequestStore is provided", async () => {
+ const customStore = createInMemoryAuthnRequestStore();
+
+ const { auth, signInWithTestUser } = await getTestInstance({
+ plugins: [
+ sso({
+ saml: {
+ authnRequestStore: customStore,
+ allowIdpInitiated: false,
+ },
+ }),
+ ],
+ });
+
+ const { headers } = await signInWithTestUser();
+
+ await auth.api.registerSSOProvider({
+ body: {
+ providerId: "custom-store-provider",
+ issuer: "http://localhost:8081",
+ domain: "http://localhost:8081",
+ samlConfig: {
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
+ cert: certificate,
+ callbackUrl: "http://localhost:3000/dashboard",
+ wantAssertionsSigned: false,
+ signatureAlgorithm: "sha256",
+ digestAlgorithm: "sha256",
+ idpMetadata: {
+ metadata: idpMetadata,
+ },
+ spMetadata: {
+ metadata: spMetadata,
+ },
+ identifierFormat:
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
+ },
+ },
+ headers,
+ });
+
+ let samlResponse: any;
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
+ onSuccess: async (context) => {
+ samlResponse = await context.data;
+ },
+ });
+
+ const response = await auth.handler(
+ new Request(
+ "http://localhost:3000/api/auth/sso/saml2/callback/custom-store-provider",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({
+ SAMLResponse: samlResponse.samlResponse,
+ RelayState: "http://localhost:3000/dashboard",
+ }),
+ },
+ ),
+ );
+
+ expect(response.status).toBe(302);
+ const redirectLocation = response.headers.get("location") || "";
+ expect(redirectLocation).toContain("error=unsolicited_response");
+ });
+
+ it("should use verification table for InResponseTo validation when no custom store is provided", async () => {
+ // When enableInResponseToValidation is true and no custom authnRequestStore is provided,
+ // the plugin uses the verification table (database) for storing AuthnRequest IDs
+ const { auth, signInWithTestUser } = await getTestInstance({
+ plugins: [
+ sso({
+ saml: {
+ enableInResponseToValidation: true,
+ allowIdpInitiated: false,
+ },
+ }),
+ ],
+ });
+
+ const { headers } = await signInWithTestUser();
+
+ await auth.api.registerSSOProvider({
+ body: {
+ providerId: "db-fallback-provider",
+ issuer: "http://localhost:8081",
+ domain: "http://localhost:8081",
+ samlConfig: {
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
+ cert: certificate,
+ callbackUrl: "http://localhost:3000/dashboard",
+ wantAssertionsSigned: false,
+ signatureAlgorithm: "sha256",
+ digestAlgorithm: "sha256",
+ idpMetadata: {
+ metadata: idpMetadata,
+ },
+ spMetadata: {
+ metadata: spMetadata,
+ },
+ identifierFormat:
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
+ },
+ },
+ headers,
+ });
+
+ // Try to use an unsolicited response - should be rejected since allowIdpInitiated is false
+ // This proves the validation is working via the verification table fallback
+ let samlResponse: any;
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
+ onSuccess: async (context) => {
+ samlResponse = await context.data;
+ },
+ });
+
+ const response = await auth.handler(
+ new Request(
+ "http://localhost:3000/api/auth/sso/saml2/callback/db-fallback-provider",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({
+ SAMLResponse: samlResponse.samlResponse,
+ RelayState: "http://localhost:3000/dashboard",
+ }),
+ },
+ ),
+ );
+
+ // Should reject unsolicited response, proving validation is active
+ expect(response.status).toBe(302);
+ const redirectLocation = response.headers.get("location") || "";
+ expect(redirectLocation).toContain("error=unsolicited_response");
+ });
});
describe("SAML SSO with custom fields", () => {
diff --git a/packages/sso/src/types.ts b/packages/sso/src/types.ts
index 11da5c1049..c4c8a5150d 100644
--- a/packages/sso/src/types.ts
+++ b/packages/sso/src/types.ts
@@ -1,4 +1,5 @@
import type { OAuth2Tokens, User } from "better-auth";
+import type { AuthnRequestStore } from "./authn-request-store";
export interface OIDCMapping {
id?: string | undefined;
@@ -259,4 +260,52 @@ export interface SSOOptions {
*/
tokenPrefix?: string;
};
+ /**
+ * SAML security options for AuthnRequest/InResponseTo validation.
+ * This prevents unsolicited responses, replay attacks, and cross-provider injection.
+ */
+ saml?: {
+ /**
+ * Enable InResponseTo validation for SP-initiated SAML flows.
+ * When enabled, AuthnRequest IDs are tracked and validated against SAML responses.
+ *
+ * Storage behavior:
+ * - Uses `secondaryStorage` (e.g., Redis) if configured in your auth options
+ * - Falls back to the verification table in the database otherwise
+ *
+ * This works correctly in serverless environments without any additional configuration.
+ *
+ * @default false
+ */
+ enableInResponseToValidation?: boolean;
+ /**
+ * Allow IdP-initiated SSO (unsolicited SAML responses).
+ * When true, responses without InResponseTo are accepted.
+ * When false, all responses must correlate to a stored AuthnRequest.
+ *
+ * Only applies when InResponseTo validation is enabled.
+ *
+ * @default true
+ */
+ allowIdpInitiated?: boolean;
+ /**
+ * TTL for AuthnRequest records in milliseconds.
+ * Requests older than this will be rejected.
+ *
+ * Only applies when InResponseTo validation is enabled.
+ *
+ * @default 300000 (5 minutes)
+ */
+ requestTTL?: number;
+ /**
+ * Custom AuthnRequest store implementation.
+ * Use this to provide a custom storage backend (e.g., Redis-backed store).
+ *
+ * Providing a custom store automatically enables InResponseTo validation.
+ *
+ * Note: When not provided, the default storage (secondaryStorage with
+ * verification table fallback) is used automatically.
+ */
+ authnRequestStore?: AuthnRequestStore;
+ };
}