mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-28 06:47:20 -05:00
refactor(sso): adopt saml-pipeline and port security fixes
Use main's saml-pipeline.ts for SAML response processing, replacing ~1000 lines of duplicated inline code in sso.ts. Port the four security fixes from #9055 into the pipeline: - Fix InResponseTo field path (extract.response?.inResponseTo) - Add Audience Restriction validation - Fix SessionIndex stored as string, not object - Fix allowIdpInitiated default to false Delegate to validateInResponseTo/validateAudience from response-validation.ts instead of reimplementing inline. Graft private_key_jwt (RFC 7523) support from #8836 onto main's pipeline-based sso.ts.
This commit is contained in:
@@ -31,13 +31,6 @@ export {
|
||||
DEFAULT_MAX_SAML_METADATA_SIZE,
|
||||
DEFAULT_MAX_SAML_RESPONSE_SIZE,
|
||||
} from "./constants";
|
||||
|
||||
export {
|
||||
type SAMLConditions,
|
||||
type TimestampValidationOptions,
|
||||
validateSAMLTimestamp,
|
||||
} from "./routes/sso";
|
||||
|
||||
export {
|
||||
type AlgorithmValidationOptions,
|
||||
DataEncryptionAlgorithm,
|
||||
@@ -46,6 +39,11 @@ export {
|
||||
KeyEncryptionAlgorithm,
|
||||
SignatureAlgorithm,
|
||||
} from "./saml";
|
||||
export {
|
||||
type SAMLConditions,
|
||||
type TimestampValidationOptions,
|
||||
validateSAMLTimestamp,
|
||||
} from "./saml/timestamp";
|
||||
|
||||
import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "./types";
|
||||
import { PACKAGE_VERSION } from "./version";
|
||||
|
||||
@@ -7,12 +7,16 @@ import type { FlowResult } from "samlify/types/src/flow";
|
||||
|
||||
import * as constants from "../constants";
|
||||
import { assignOrganizationFromProvider } from "../linking";
|
||||
import { validateSAMLAlgorithms, validateSingleAssertion } from "../saml";
|
||||
import {
|
||||
validateAudience,
|
||||
validateInResponseTo,
|
||||
validateSAMLAlgorithms,
|
||||
validateSingleAssertion,
|
||||
} from "../saml";
|
||||
import type { SAMLConditions } from "../saml/timestamp";
|
||||
import { validateSAMLTimestamp } from "../saml/timestamp";
|
||||
import { parseRelayState } from "../saml-state";
|
||||
import type {
|
||||
AuthnRequestRecord,
|
||||
SAMLAssertionExtract,
|
||||
SAMLConfig,
|
||||
SAMLSessionRecord,
|
||||
@@ -240,75 +244,25 @@ export async function processSAMLResponse(
|
||||
});
|
||||
|
||||
// 12. InResponseTo validation
|
||||
const inResponseTo = (extract as SAMLAssertionExtract).inResponseTo as
|
||||
| string
|
||||
| undefined;
|
||||
const shouldValidateInResponseTo =
|
||||
options?.saml?.enableInResponseToValidation !== false;
|
||||
await validateInResponseTo(ctx, {
|
||||
extract: extract as SAMLAssertionExtract,
|
||||
providerId,
|
||||
options: {
|
||||
enableInResponseToValidation: options?.saml?.enableInResponseToValidation,
|
||||
allowIdpInitiated: options?.saml?.allowIdpInitiated,
|
||||
},
|
||||
redirectUrl: samlRedirectUrl,
|
||||
});
|
||||
|
||||
if (shouldValidateInResponseTo) {
|
||||
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
||||
// 13. Audience restriction validation
|
||||
validateAudience(ctx, {
|
||||
extract: extract as SAMLAssertionExtract,
|
||||
expectedAudience: parsedSamlConfig.audience,
|
||||
providerId,
|
||||
redirectUrl: samlRedirectUrl,
|
||||
});
|
||||
|
||||
if (inResponseTo) {
|
||||
let storedRequest: AuthnRequestRecord | null = null;
|
||||
|
||||
const verification =
|
||||
await ctx.context.internalAdapter.findVerificationValue(
|
||||
`${constants.AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
||||
);
|
||||
if (verification) {
|
||||
try {
|
||||
storedRequest = JSON.parse(verification.value) as AuthnRequestRecord;
|
||||
if (storedRequest && storedRequest.expiresAt < Date.now()) {
|
||||
storedRequest = null;
|
||||
}
|
||||
} catch {
|
||||
storedRequest = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!storedRequest) {
|
||||
ctx.context.logger.error(
|
||||
"SAML InResponseTo validation failed: unknown or expired request ID",
|
||||
{ inResponseTo, providerId },
|
||||
);
|
||||
throw ctx.redirect(
|
||||
`${samlRedirectUrl}?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,
|
||||
expectedProvider: storedRequest.providerId,
|
||||
actualProvider: providerId,
|
||||
},
|
||||
);
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
`${constants.AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
||||
);
|
||||
throw ctx.redirect(
|
||||
`${samlRedirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
|
||||
);
|
||||
}
|
||||
|
||||
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
||||
`${constants.AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
||||
);
|
||||
} else if (!allowIdpInitiated) {
|
||||
ctx.context.logger.error(
|
||||
"SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
|
||||
{ providerId },
|
||||
);
|
||||
throw ctx.redirect(
|
||||
`${samlRedirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 13. Replay protection
|
||||
// 14. Replay protection
|
||||
const samlContent = (parsedResponse as any).samlContent as string | undefined;
|
||||
const assertionId = samlContent ? extractAssertionId(samlContent) : null;
|
||||
|
||||
@@ -371,7 +325,7 @@ export async function processSAMLResponse(
|
||||
);
|
||||
}
|
||||
|
||||
// 14. User attribute extraction
|
||||
// 15. User attribute extraction
|
||||
const attributes = extract.attributes || {};
|
||||
const mapping = parsedSamlConfig.mapping ?? {};
|
||||
|
||||
@@ -412,7 +366,7 @@ export async function processSAMLResponse(
|
||||
});
|
||||
}
|
||||
|
||||
// 15. Session creation
|
||||
// 16. Session creation
|
||||
const isTrustedProvider: boolean =
|
||||
ctx.context.trustedProviders.includes(providerId) ||
|
||||
("domainVerified" in provider &&
|
||||
@@ -454,7 +408,7 @@ export async function processSAMLResponse(
|
||||
|
||||
const { session, user } = result.data!;
|
||||
|
||||
// 16. Provision user
|
||||
// 17. Provision user
|
||||
if (
|
||||
options?.provisionUser &&
|
||||
(result.isRegister || options.provisionUserOnEveryLogin)
|
||||
@@ -466,7 +420,7 @@ export async function processSAMLResponse(
|
||||
});
|
||||
}
|
||||
|
||||
// 17. Organization assignment
|
||||
// 18. Organization assignment
|
||||
await assignOrganizationFromProvider(ctx as any, {
|
||||
user,
|
||||
profile: {
|
||||
@@ -481,17 +435,18 @@ export async function processSAMLResponse(
|
||||
provisioningOptions: options?.organizationProvisioning,
|
||||
});
|
||||
|
||||
// 18. Set session cookie
|
||||
// 19. Set session cookie
|
||||
await setSessionCookie(ctx, { session, user });
|
||||
|
||||
// 19. SLO session record
|
||||
// 20. SLO session record
|
||||
if (options?.saml?.enableSingleLogout && extract.nameID) {
|
||||
const samlSessionKey = `${constants.SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
|
||||
const samlSessionData: SAMLSessionRecord = {
|
||||
sessionId: session.id,
|
||||
providerId,
|
||||
nameID: extract.nameID,
|
||||
sessionIndex: (extract as SAMLAssertionExtract).sessionIndex,
|
||||
sessionIndex: (extract as SAMLAssertionExtract).sessionIndex
|
||||
?.sessionIndex,
|
||||
};
|
||||
await ctx.context.internalAdapter
|
||||
.createVerificationValue({
|
||||
@@ -518,7 +473,7 @@ export async function processSAMLResponse(
|
||||
);
|
||||
}
|
||||
|
||||
// 20. Compute safe redirect URL
|
||||
// 21. Compute safe redirect URL
|
||||
return getSafeRedirectUrl(
|
||||
relayState?.callbackURL || parsedSamlConfig.callbackUrl,
|
||||
currentCallbackPath,
|
||||
|
||||
+98
-1039
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user