mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-23 23:52:05 -05:00
feat(sso): add InResponseTo validation (#6557)
This commit is contained in:
committed by
GitHub
parent
a0a1633208
commit
50ffecbfad
@@ -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",
|
||||
|
||||
76
packages/sso/src/authn-request-store.ts
Normal file
76
packages/sso/src/authn-request-store.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
99
packages/sso/src/authn-request.test.ts
Normal file
99
packages/sso/src/authn-request.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 ?? {};
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user