feat(sso): add InResponseTo validation (#6557)

This commit is contained in:
Paola Estefanía de Campos
2025-12-11 20:57:00 -03:00
committed by GitHub
parent a0a1633208
commit 50ffecbfad
7 changed files with 922 additions and 9 deletions

View File

@@ -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
<Callout type="info">
This feature is **opt-in** to ensure backward compatibility. Enable it explicitly for enhanced security.
</Callout>
#### 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)
<Callout type="warning">
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.
</Callout>
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",

View File

@@ -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<void>;
get(id: string): Promise<AuthnRequestRecord | null>;
delete(id: string): Promise<void>;
}
/**
* 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<string, AuthnRequestRecord>();
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<void> {
store.set(record.id, record);
},
async get(id: string): Promise<AuthnRequestRecord | null> {
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<void> {
store.delete(id);
},
};
}

View File

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

View File

@@ -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<O extends SSOOptions>(
};
export function sso<O extends SSOOptions>(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 = {

View File

@@ -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 ?? {};

View File

@@ -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", () => {

View File

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