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