Files
better-auth/packages/sso/src/saml.test.ts

8767 lines
269 KiB
TypeScript

import {
createPrivateKey,
createPublicKey,
createVerify,
randomUUID,
} from "node:crypto";
import type { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";
import { NodeSqliteDialect } from "@better-auth/kysely-adapter/node-sqlite-dialect";
import { betterFetch } from "@better-fetch/fetch";
import type { Account, DBTransactionAdapter, User } from "better-auth";
import { betterAuth } from "better-auth";
import { memoryAdapter } from "better-auth/adapters/memory";
import { APIError } from "better-auth/api";
import { createAuthClient } from "better-auth/client";
import { parseSetCookieHeader, setCookieToHeader } from "better-auth/cookies";
import { bearer } from "better-auth/plugins";
import { getHttpTestInstance, getTestInstance } from "better-auth/test";
import bodyParser from "body-parser";
import type {
Application as ExpressApp,
Request as ExpressRequest,
Response as ExpressResponse,
} from "express";
import express from "express";
import { Kysely } from "kysely";
import type { RequestInfo } from "samlify/types/src/types";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { getMigrations } from "../../better-auth/src/db/get-migration";
import { getAuthStateVerificationIdentifier } from "../../better-auth/src/state";
import { sso, validateSAMLTimestamp } from ".";
import { ssoClient } from "./client";
import { DEFAULT_CLOCK_SKEW_MS } from "./constants";
import { computeSSOProviderReference } from "./provider-reference";
import { findSAMLProvider } from "./routes/helpers";
import { getSafeRedirectUrl } from "./routes/saml-pipeline";
import { saml } from "./samlify";
import type { SSOOptions, SSOUserResolutionInput } from "./types";
import { normalizePem } from "./utils";
const spMetadata = `
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://localhost:3001/api/sso/saml2/sp/metadata">
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>MIIE3jCCAsYCCQDE5FzoAkixzzANBgkqhkiG9w0BAQsFADAxMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzAeFw0yMzExMTkxMjUyMTVaFw0zMzExMTYxMjUyMTVaMDExCzAJBgNVBAYTAlVTMRAwDgYDVQQIDAdGbG9yaWRhMRAwDgYDVQQHDAdPcmxhbmRvMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA2ELJsLZs4yBH7a2U5pA7xw+Oiut7b/ROKh2BqSTKRbEG4xy7WwljT02Mh7GTjLvswtZSUObWFO5v14HNORa3+J9JT2DH+9F+FJ770HX8a3cKYBNQt3xP4IeUyjI3QWzrGtkYPwSZ74tDpAUtuqPAxtoCaZXFDtX6lvCJDqiPnfxRZrKkepYWINSwu4DRpg6KoiPWRCYTsEcCzImInzlACdM97jpG1gLGA6a4dmjalQbRtvC56N0Z56gIhYq2F5JdzB2a10pqoIY8ggXZGIJS9I++8mmdTj6So5pPxLwnCYUhwDew1/DMbi9xIwYozs9pEtHCTn1l34jldDwTziVAxGQZO7QUuoMl997zqcPS7pVWRnfz5odKuytLvQDA0lRVfzOxtqbM3qVhoLT2iDmnuEtlZzgfbt4WEuT2538qxZJkFRpZQIrTj3ybqmWAv36Cp49dfeMwaqjhfX7/mVfbsPMSC653DSZBB+n+Uz0FC3QhH+vIdNhXNAQ5tBseHUR6pXiMnLtI/WVbMvpvFwK2faFTcx1oaP/Qk6yCq66tJvPbnatT9qGF8rdBJmAk9aBdQTI+hAh5mDtDweCrgVL+Tm/+Q85hSl4HGzH/LhLVS478tZVX+o+0yorZ35LCW3e4v8iX+1VEGSdg2ooOWtbSSXK2cYZr8ilyUQp0KueenR0CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAsonAahruWuHlYbDNQVD0ryhL/b+ttKKqVeT87XYDkvVhlSSSVAKcCwK/UU6z8Ty9dODUkd93Qsbof8fGMlXeYCtDHMRanvWLtk4wVkAMyNkDYHzJ1FbO7v44ZBbqNzSLy2kosbRELlcz+P3/42xumlDqAw/k13tWUdlLDxb0pd8R5yBev6HkIdJBIWtKmUuI+e8F/yTNf5kY7HO1p0NeKdVeZw4Ydw33+BwVxVNmhIxzdP5ZFQv0XRFWhCMo/6RLEepCvWUp/T1WRFqgwAdURaQrvvfpjO/Ls+neht1SWDeP8RRgsDrXIc3gZfaD8q4liIDTZ6HsFi7FmLbZatU8jJ4pCstxQLCvmix+1zF6Fwa9V5OApSTbVqBOsDZbJxeAoSzy5Wx28wufAZT4Kc/OaViXPV5o/ordPs4EYKgd/eNFCgIsZYXe75rYXqnieAIfJEGddsLBpqlgLkwvf5KVS4QNqqX+2YubP63y+3sICq2ScdhO3LZs3nlqQ/SgMiJnCBbDUDZ9GGgJNJVVytcSz5IDQHeflrq/zTt1c4q1DO3CS7mimAnTCjetERRQ3mgY/2hRiuCDFj3Cy7QMjFs3vBsbWrjNWlqyveFmHDRkq34Om7eA2jl3LZ5u7vSm0/ylp/vtoysMjwEmw/0NA3hZPTG3OJxcvFcXBsz0SiFcd1U=</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:KeyDescriptor use="encryption">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>MIIE3jCCAsYCCQDE5FzoAkixzzANBgkqhkiG9w0BAQsFADAxMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzAeFw0yMzExMTkxMjUyMTVaFw0zMzExMTYxMjUyMTVaMDExCzAJBgNVBAYTAlVTMRAwDgYDVQQIDAdGbG9yaWRhMRAwDgYDVQQHDAdPcmxhbmRvMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA2ELJsLZs4yBH7a2U5pA7xw+Oiut7b/ROKh2BqSTKRbEG4xy7WwljT02Mh7GTjLvswtZSUObWFO5v14HNORa3+J9JT2DH+9F+FJ770HX8a3cKYBNQt3xP4IeUyjI3QWzrGtkYPwSZ74tDpAUtuqPAxtoCaZXFDtX6lvCJDqiPnfxRZrKkepYWINSwu4DRpg6KoiPWRCYTsEcCzImInzlACdM97jpG1gLGA6a4dmjalQbRtvC56N0Z56gIhYq2F5JdzB2a10pqoIY8ggXZGIJS9I++8mmdTj6So5pPxLwnCYUhwDew1/DMbi9xIwYozs9pEtHCTn1l34jldDwTziVAxGQZO7QUuoMl997zqcPS7pVWRnfz5odKuytLvQDA0lRVfzOxtqbM3qVhoLT2iDmnuEtlZzgfbt4WEuT2538qxZJkFRpZQIrTj3ybqmWAv36Cp49dfeMwaqjhfX7/mVfbsPMSC653DSZBB+n+Uz0FC3QhH+vIdNhXNAQ5tBseHUR6pXiMnLtI/WVbMvpvFwK2faFTcx1oaP/Qk6yCq66tJvPbnatT9qGF8rdBJmAk9aBdQTI+hAh5mDtDweCrgVL+Tm/+Q85hSl4HGzH/LhLVS478tZVX+o+0yorZ35LCW3e4v8iX+1VEGSdg2ooOWtbSSXK2cYZr8ilyUQp0KueenR0CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAsonAahruWuHlYbDNQVD0ryhL/b+ttKKqVeT87XYDkvVhlSSSVAKcCwK/UU6z8Ty9dODUkd93Qsbof8fGMlXeYCtDHMRanvWLtk4wVkAMyNkDYHzJ1FbO7v44ZBbqNzSLy2kosbRELlcz+P3/42xumlDqAw/k13tWUdlLDxb0pd8R5yBev6HkIdJBIWtKmUuI+e8F/yTNf5kY7HO1p0NeKdVeZw4Ydw33+BwVxVNmhIxzdP5ZFQv0XRFWhCMo/6RLEepCvWUp/T1WRFqgwAdURaQrvvfpjO/Ls+neht1SWDeP8RRgsDrXIc3gZfaD8q4liIDTZ6HsFi7FmLbZatU8jJ4pCstxQLCvmix+1zF6Fwa9V5OApSTbVqBOsDZbJxeAoSzy5Wx28wufAZT4Kc/OaViXPV5o/ordPs4EYKgd/eNFCgIsZYXe75rYXqnieAIfJEGddsLBpqlgLkwvf5KVS4QNqqX+2YubP63y+3sICq2ScdhO3LZs3nlqQ/SgMiJnCBbDUDZ9GGgJNJVVytcSz5IDQHeflrq/zTt1c4q1DO3CS7mimAnTCjetERRQ3mgY/2hRiuCDFj3Cy7QMjFs3vBsbWrjNWlqyveFmHDRkq34Om7eA2jl3LZ5u7vSm0/ylp/vtoysMjwEmw/0NA3hZPTG3OJxcvFcXBsz0SiFcd1U=</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:3001/api/sso/saml2/sp/sls"/>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:3001/api/sso/saml2/sp/acs" index="1"/>
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:3001/api/sso/saml2/sp/acs" index="1"/>
</md:SPSSODescriptor>
<md:Organization>
<md:OrganizationName xml:lang="en-US">Organization Name</md:OrganizationName>
<md:OrganizationDisplayName xml:lang="en-US">Organization DisplayName</md:OrganizationDisplayName>
<md:OrganizationURL xml:lang="en-US">http://localhost:3001/</md:OrganizationURL>
</md:Organization>
<md:ContactPerson contactType="technical">
<md:GivenName>Technical Contact Name</md:GivenName>
<md:EmailAddress>technical_contact@gmail.com</md:EmailAddress>
</md:ContactPerson>
<md:ContactPerson contactType="support">
<md:GivenName>Support Contact Name</md:GivenName>
<md:EmailAddress>support_contact@gmail.com</md:EmailAddress>
</md:ContactPerson>
</md:EntityDescriptor>
`;
const idpMetadata = `
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://localhost:8081/api/sso/saml2/idp/metadata">
<md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>MIIFOjCCAyICCQCqP5DN+xQZDjANBgkqhkiG9w0BAQsFADBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wHhcNMjMxMTE5MTIzNzE3WhcNMzMxMTE2MTIzNzE3WjBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQD5giLoLyED41IHt0RxB/k6x4K0vzAKiGecPyedRNR1oyiv3OYkuG5jgTE2wcPZc7kD1Eg5d6th0BWHy/ovaNS5mkgnOV6jKkMaWW4sCMSnLnaWy0seftPK3O4mNeZpM5e9amj2gXnZvKrK8cqnJ/bsUUQvXxttXNVVmOHWg/t3c2vJ4XuUfph6wIKbrj297ILzuAFRNvAVxeS0tElwepvZ5Wbf7Hc1MORAqTpw/mp8cRjHRzYCA9y6OM4hgVs1gvTJS8WGoMmsdAZHaOnv9vLJvW3jDLQQecOheYIJncWgcESzJFIkmXadorYCEfWhwwBdVphknmeLr4BMpJBclAYaFjYDLIKpMcXYO5k/2r3BgSPlw4oqbxbR5geD05myKYtZ/wNUtku118NjhIfJFulU/kfDcp1rYYkvzgBfqr80wgNps4oQzVr1mnpgHsSTAhXMuZbaTByJRmPqecyvyQqRQcRIN0oTLJNGyzoUf0RkH6DKJ4+7qDhlq4Zhlfso9OFMv9xeONfIrJo5HtTfFZfidkXZqir2ZqwqNlNOMfK5DsYq37x2Gkgqig4nqLpITXyxfnQpL2HsaoFrlctt/OL+Zqba7NT4heYk9GX8qlAS+Ipsv6T2HSANbah55oSS3uvcrDOug2Zq7+GYMLKS1IKUKhwX+wLMxmMwSJQ9ZgFwfQIDAQABMA0GCSqGSIb3DQEBCwUAA4ICAQCkGPZdflocTSXIe5bbehsBn/IPdyb38eH2HaAvWqO2XNcDcq+6/uLc8BVK4JMa3AFS9xtBza7MOXN/lw/Ccb8uJGVNUE31+rTvsJaDtMCQkp+9aG04I1BonEHfSB0ANcTy/Gp+4hKyFCd6x35uyPO7CWX5Z8I87q9LF6Dte3/v1j7VZgDjAi9yHpBJv9Xje33AK1vF+WmEfDUOi8y2B8htVeoyS3owln3ZUbnmJdCmMp2BMRq63ymINwklEaYaNrp1L201bSqNdKZF2sNwROWyDX+WFYgufrnzPYb6HS8gYb4oEZmaG5cBM7Hs730/3BlbHKhxNTy1Io2TVCYcMQD+ieiVg5e5eGTwaPYGuVvY3NVhO8FaYBG7K2NT2hqutdCMaQpGyHEzbbbTY1afhbeMmWWqivRnVJNDv4kgBc2SE8JO82qHikIW9Om0cghC5xwTT+1JTtxxD1KeC1M1IwLzzuuMmwJSKAsv4duDqN+YRIP78J2SlrssqlsmoF8+48e7Vzr7JRT/Ya274P8RpUPNtxTR7WDmZ4tunqXjiBpz6l0uTtVXnj5UBo4HCyRjWJOGf15OCuQX03qz8tKn1IbZUf723qrmSF+cxBwHqpAywqhTSsaLjIXKnQ0UlMov7QWb0a5N07JZMdMSerbHvbXd/z9S1Ssea2+EGuTYuQur3A==</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:KeyDescriptor use="encryption">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>MIIFOjCCAyICCQCqP5DN+xQZDjANBgkqhkiG9w0BAQsFADBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wHhcNMjMxMTE5MTIzNzE3WhcNMzMxMTE2MTIzNzE3WjBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQD5giLoLyED41IHt0RxB/k6x4K0vzAKiGecPyedRNR1oyiv3OYkuG5jgTE2wcPZc7kD1Eg5d6th0BWHy/ovaNS5mkgnOV6jKkMaWW4sCMSnLnaWy0seftPK3O4mNeZpM5e9amj2gXnZvKrK8cqnJ/bsUUQvXxttXNVVmOHWg/t3c2vJ4XuUfph6wIKbrj297ILzuAFRNvAVxeS0tElwepvZ5Wbf7Hc1MORAqTpw/mp8cRjHRzYCA9y6OM4hgVs1gvTJS8WGoMmsdAZHaOnv9vLJvW3jDLQQecOheYIJncWgcESzJFIkmXadorYCEfWhwwBdVphknmeLr4BMpJBclAYaFjYDLIKpMcXYO5k/2r3BgSPlw4oqbxbR5geD05myKYtZ/wNUtku118NjhIfJFulU/kfDcp1rYYkvzgBfqr80wgNps4oQzVr1mnpgHsSTAhXMuZbaTByJRmPqecyvyQqRQcRIN0oTLJNGyzoUf0RkH6DKJ4+7qDhlq4Zhlfso9OFMv9xeONfIrJo5HtTfFZfidkXZqir2ZqwqNlNOMfK5DsYq37x2Gkgqig4nqLpITXyxfnQpL2HsaoFrlctt/OL+Zqba7NT4heYk9GX8qlAS+Ipsv6T2HSANbah55oSS3uvcrDOug2Zq7+GYMLKS1IKUKhwX+wLMxmMwSJQ9ZgFwfQIDAQABMA0GCSqGSIb3DQEBCwUAA4ICAQCkGPZdflocTSXIe5bbehsBn/IPdyb38eH2HaAvWqO2XNcDcq+6/uLc8BVK4JMa3AFS9xtBza7MOXN/lw/Ccb8uJGVNUE31+rTvsJaDtMCQkp+9aG04I1BonEHfSB0ANcTy/Gp+4hKyFCd6x35uyPO7CWX5Z8I87q9LF6Dte3/v1j7VZgDjAi9yHpBJv9Xje33AK1vF+WmEfDUOi8y2B8htVeoyS3owln3ZUbnmJdCmMp2BMRq63ymINwklEaYaNrp1L201bSqNdKZF2sNwROWyDX+WFYgufrnzPYb6HS8gYb4oEZmaG5cBM7Hs730/3BlbHKhxNTy1Io2TVCYcMQD+ieiVg5e5eGTwaPYGuVvY3NVhO8FaYBG7K2NT2hqutdCMaQpGyHEzbbbTY1afhbeMmWWqivRnVJNDv4kgBc2SE8JO82qHikIW9Om0cghC5xwTT+1JTtxxD1KeC1M1IwLzzuuMmwJSKAsv4duDqN+YRIP78J2SlrssqlsmoF8+48e7Vzr7JRT/Ya274P8RpUPNtxTR7WDmZ4tunqXjiBpz6l0uTtVXnj5UBo4HCyRjWJOGf15OCuQX03qz8tKn1IbZUf723qrmSF+cxBwHqpAywqhTSsaLjIXKnQ0UlMov7QWb0a5N07JZMdMSerbHvbXd/z9S1Ssea2+EGuTYuQur3A==</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:8081/api/sso/saml2/idp/slo"/>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:8081/api/sso/saml2/idp/redirect"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:8081/api/sso/saml2/idp/post"/>
</md:IDPSSODescriptor>
<md:Organization>
<md:OrganizationName xml:lang="en-US">Your Organization Name</md:OrganizationName>
<md:OrganizationDisplayName xml:lang="en-US">Your Organization DisplayName</md:OrganizationDisplayName>
<md:OrganizationURL xml:lang="en-US">http://localhost:8081</md:OrganizationURL>
</md:Organization>
<md:ContactPerson contactType="technical">
<md:GivenName>Technical Contact Name</md:GivenName>
<md:EmailAddress>technical_contact@gmail.com</md:EmailAddress>
</md:ContactPerson>
<md:ContactPerson contactType="support">
<md:GivenName>Support Contact Name</md:GivenName>
<md:EmailAddress>support_contact@gmail.com</md:EmailAddress>
</md:ContactPerson>
</md:EntityDescriptor>
`;
function extractSigningCertificateFromMetadata(metadata: string): string {
const certificateMatch = metadata.match(
/<ds:X509Certificate>([^<]+)<\/ds:X509Certificate>/,
);
const certificateBody = certificateMatch?.[1]?.trim();
if (!certificateBody) {
throw new Error("IdP metadata does not contain a signing certificate");
}
return `-----BEGIN CERTIFICATE-----\n${certificateBody}\n-----END CERTIFICATE-----`;
}
const mockIdentityProviderMetadata = {
entityID: "http://localhost:8081/api/sso/saml2/idp/metadata",
};
const idPk = `
-----BEGIN RSA PRIVATE KEY-----
MIIJKgIBAAKCAgEA+YIi6C8hA+NSB7dEcQf5OseCtL8wCohnnD8nnUTUdaMor9zm
JLhuY4ExNsHD2XO5A9RIOXerYdAVh8v6L2jUuZpIJzleoypDGlluLAjEpy52lstL
Hn7TytzuJjXmaTOXvWpo9oF52byqyvHKpyf27FFEL18bbVzVVZjh1oP7d3NryeF7
lH6YesCCm649veyC87gBUTbwFcXktLRJcHqb2eVm3+x3NTDkQKk6cP5qfHEYx0c2
AgPcujjOIYFbNYL0yUvFhqDJrHQGR2jp7/byyb1t4wy0EHnDoXmCCZ3FoHBEsyRS
JJl2naK2AhH1ocMAXVaYZJ5ni6+ATKSQXJQGGhY2AyyCqTHF2DuZP9q9wYEj5cOK
Km8W0eYHg9OZsimLWf8DVLZLtdfDY4SHyRbpVP5Hw3Kda2GJL84AX6q/NMIDabOK
EM1a9Zp6YB7EkwIVzLmW2kwciUZj6nnMr8kKkUHESDdKEyyTRss6FH9EZB+gyieP
u6g4ZauGYZX7KPThTL/cXjjXyKyaOR7U3xWX4nZF2aoq9masKjZTTjHyuQ7GKt+8
dhpIKooOJ6i6SE18sX50KS9h7GqBa5XLbfzi/mam2uzU+IXmJPRl/KpQEviKbL+k
9h0gDW2oeeaEkt7r3KwzroNmau/hmDCyktSClCocF/sCzMZjMEiUPWYBcH0CAwEA
AQKCAgABJVzdriG7r9aXnHre/gdiArqR8/LXiYrYR935tfA33hj4vc38yzAOmvBL
7RXmMMbfwqDWSrtpxpfiuMgcYaHgfFnqfDP4EeCfBVwhLaUhk3AN/z8IE9MLMnqR
iFvXjdobj5qNz0hs/JXYOsYQgHl82l6yzQAGP4/nRb17y71i7g/HrJZxtyciITI4
XtN/xM9RKT4wTk1J/E+xmMZhkt6WYJxZWO+vOdtChMR08mYwziAsAiK4XaYs4Mfp
lXuCwmg3aHauyJxEg3/n4g55AKxaytjvWwaUsMp6OmGjg6r9sqZOIFOUQXQvAylM
1yJGrOuagiRPCf81wAeZ0oOrOS7R+4fF4Ypa+V7Cp6Ty3VPcw8BFpXJ6fRtf92kh
ix00DnFEK/TdndyBpFKdmf8f2SSFBLrPlmTfjdMAvShE5yFpeWyXQjftI5q/0d3U
Ug0MBby66yT/TZtTKVPdK6bG3fYvzgKCpZGrKgn+umq4XR+gh9S0ptmwNF5mzJy4
mol5CkazGPlOSwlBc4oKeepcqZ0TKCJwonub90CJeH8IKoyRsswShRl6YTRza1SB
Fx4Gis5xcaNp7eXnLBDgKV/1bhCUSvQ886r+Xo4nfhk9n8WrtaQFC4tFID1e8TAM
jYxZIBpCHOZHX/+BpC3FyqD4RbI12iudyz4KwS5Ps/wlIpVMQQKCAQEA/70X3Fz2
SJyPP9UdiiqLot1ppbagQGjG20yFnfRDhNY+q2U8N77yJUXWvE7YQ6OUTOaPuJX2
X7vulTSQ0YyFYp0B5G4QiFtvPOpBvn7OxrFKBKxwbOU7L2rAuXWYEIRuKuwBRMFU
oaar8gkKlnsUtUxrLM827gmL13i3GX2bmm6NhhGCKbSCoD51+UUGo7Ix5ZLznKmX
G1mq4IxtJe8vLk/9RT9CzRV7VO61EgEh7Iji7g4cDIiZV+B9gG8YMlTOcALPpgud
nF7SEvDuMH3dgOj+iSO9piJ53okU59Mk4Nyka3p3v6RABMcDYO1/wkbE83+Oobrx
RiRQHtBgo1r9cQKCAQEA+cNpxVCi/BkatlzKentRabnQjfxrEQdIdc9xqzr5k2xK
w9n+XGzeNT+HKI/S1KkfvJTQC0j9WBQ3uupf8Zg6/mNF84YCXpun3JXpvzc+4ya3
i1AXtdul/JYU5qhMrJI+I1WXrWAls5zbIs23iz1Fq530Mb7FUQ5jmO0p123AmMGG
hSTJDqvKDMpQXdUYQMqrSL/aNh8u7wpw2S052uj2bdbdgq1FboLzbwWTOsVYs3aS
HABb95263Cf3OdRr4lyN6khFMLhQPUhYnn6l2ob0kIZ7V2f8fxKvJoTTDTxWpUgF
FrdHigaDo09WYkIukj+YdSZY/ZEAu7lyMmY0l8HNzQKCAQEA7HE3jlWknp2hE7NG
DGgpkfqDouKmZuZ4dGjbYJ5ljntGldCTTDcOSce4MYH0ERU8F51TY6XCk+B9RRXE
jvkMmY/wH/Ji9q8SuY8cGbPEGY/wj0Ge8A9AGSbp6I4AecT21lg9FARq6sneT3hs
gZRqIPT2YgdzEcFhuWWyY67uHmn4DuxBG634147oI/7dlJs75rVm5oElY/QTOGic
wWXSiU8LKurCKDqkPHI2lt7VLougw9fntu7UV5sGbahJBr/B3W277hjvL5O7Rifb
EJpOINFKBCE3RlK5ujWjTnK4te1JVtVzwYtqZQBa71KlvEkR7s8QYBcm22LXcKXX
szB9AQKCAQEAwUua8DoX6UMEiV4G1gPaXhiQb1KLCgK48XQ6ZGqf/JgyxKBRWvZm
go9H6vxkDnFVPn1tBU7XwvLirqX02uUVwwrReEaeTtnob68V2AbJhMLSCd9Sekwj
ifgc9OYLcQM9U9tKJ8PhacBbV/QduIUTBl6YPmeGDdU0/4WMfE1UYORlV2XAtLn/
BScOS5A/1OUE6qiQGJLJn/ZUn7+ApwrkrN09UYUH1x9BhwqphzJ0E3AQY9tjUZ+g
ngHQM9FSLT20Fz0XTz1V3BfBfehGM3l+jNuHWX4Ay9eJ9iWVsQihhgjW512w4AFq
n1knYaQWptjRBNlIxfUSvDYpSxgOW+SBgQKCAQEA7ikfNUZDmhyShcmIl9Hgcral
o2M/ggUVwWd9AaJD+Y/WcGoR0DPGt8UGLGTBNBwbyTgHdDzsWA+02r5r+5ArhhnP
iWQ1soQI9FpZIUCyzAjTQpfpzo5dGqpQbW9LuHJOEbDyY2wG+lFhIm4JJBJ/vws1
yt9Y170VbPXmDdLevDLmlFOILdMJWWl3hrtlU3KEogqWKDOXciYtG5Ji0+512BqH
yY9+uVNb1eu6MLU5R5U9GdvOFZZjShIhOlpZVR1K21dg5frBCWBZ0pvu4fZf2FAV
lX6+ORENSjqJsQWTaeiMoAPOj8QxQuOwUCajbVkrCZV6D49E0D9XxmZcuKCAXg==
-----END RSA PRIVATE KEY-----
`;
const spPrivateKey = `
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: DES-EDE3-CBC,9C86371F0420A091
77TqgiK/IYRgO5w3ZMyV81/gk0zN5wPTGWxoztNFjQKXCySFnrL30kUqlGituBxX
VgxwXbkoYMrd5MoDZKL5EJuf0H59hq92O0+3uwJA8QyZjOm4brQcjXKmIrkvihgs
FvpaJiGzp6kS/O7vFBDNTQWr9yY9Y3FBPcmOUWufpRp4Q5nhpSlqnMmIqZyWQUL/
YJSJETtzJVsk38lCqIxxDT3LtbGySahj0jRuRqspAZQeLTpnJqzNMC4vnJew9luC
R+UffrX7gVsnwOhNtyRzYaMsLnbRfXT8Jqx2gRHg36GxkOVgyU7e62nk9CzeC0WA
kHHCNVqqivRx9/EC0mQkkRgRzo3BZWp0o671sUsGTy57JhktiGfTnWMrl7ZfhAza
SZnjyTwuI1bTQipIkNI3aJBTP/o/gNUE1sj5D5FZlFdpq5ks2Vxww3GNx1FRrvWd
98z5CNt78ZR0ihLmdz/EakEBKBUteQu/5zPLUlwmGuou4wPuEHG2BsjGzb/d5Zfc
ElIjUV+yrMmGHvBfPyPnDUrCUyLn18S1NZiCMCdN5PqCybjhk8oMPYZhWBqp8Ymr
yHIC7BCnTJhIvgQZR6M68NwVv0aBBgH/I/DB0jADo6/B5Eajwus9i6zSv8QIbqhw
fusKtI04vxc91aP0GWRr0J/O4mkxXYNPfa3a/I7sGTXGl0k0CygckE3fLXRy/WEk
ikZt4UHqg5ZQ8vc5NSAM5f5Yx/72CU1I6ehFtxHsyE5yndpZXWp2X2S4l31e8fLs
ddOoybroJgbyLrh7JT3Yac3XOEsKATWIvqU+hNYq6KwqLWev9jInHVgjzfyOKbmF
hkrzDDHaKULYZuTsUq5mLc1SzSu98lXYfXp1WE4XsH0X0VicPzf8ZH4Kutuig0VG
5Kg9HB/Cin65VMm0ffEiTraO6johIlwFGRrtAs38ONKgsPCQUv7ee9SEGOHViNZq
NpWPr1KOzbI4wEB1ueKoZuEQ0a+tzfJgszJrM48bM82J6iEjN/PSOTsdTKJq9e47
dlUp+tqQsvGkbBOIOt5OOpkr8Z+8qbEd21ojF9Q0p0T4WMThRP6YBRKvt8mmFwRs
DjEhMiPa4L70Eqldfu2lWdI6ietfHrK97WXwQO1gF73LOnA+EdMXNxr1iLd0Tdke
z6fUSw3hKZL+I7nX6O40+KgkhXVSZOsRz5CEvo2iChIUrYGEGDl94K/ofqGu71Y+
G8KBvbha6EC7xcUrTYP5Gek5wsrw7cGgDZJjMsyXYFBZjQO1N6g9fncLmc5pB5Ix
W3gLfQS/My4daWNTvrYOgfA08J4M4ZWd0v5TglxOSV78psG4J4slppDySNFB2d/3
7JiwWVm5SMk0StLWwb2azmTvBoinnrZJzPnPlOytxvE5uGJ/i0WAik7C99YgVJkS
9hO3FJGasrOnHeiOvMZEdRuIVspKz9iMFx7hWHpVHTTyjwceEpaiEkhmqLM9QkKh
kCZqeWyVsKBIc0sse+CKNK8ik9eTeUlCklGMV1Q4kKjR6uuHUOLyjk/xhqslV4TS
jnnjCjsK5YzTa4hmbHhPZIW262KoFV9TqxYKkhP5ab7AXRSakrdrY2cwACWN4AMT
-----END RSA PRIVATE KEY-----
`;
const idpPrivateKey = `
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: DES-EDE3-CBC,116B0EBB2F2F0A9D
HMmUsJPVPTsq1e06yrrskfinY21OOHosfRzibLueBg9ByFFZ7+/oW/DKy1GcDeBc
ycL+3gylIoGUYuZ+DPC11ArjdxFqLFnHJb96rwy5h4sTP0lE+qHy+06AwsowUgp3
pdD2unPFeydpu5h/dqgoDzkGSucz0Ty/spHXNBvns0vJO18B7XlzXUtfH5aHco22
DyVY6FrJwMts9E4Rzs9JsxJJ7mi/6+Qsc0rOr8/6KKsRo1sKD6cvQIQ05dEvGrE9
/2fubHkRTl+zBqOVyQvC6iUtocwxlMP4KfmyYrD1wlQAnP/+smq2G+xf7uGc4X4P
8q0jEy2P9n5ASlwZ3XCS9hZgp8VRAcXWOYjzzNouQp3NEP9d5D3wN4aFKa/JW6pk
a6VwraEweuyJqvZ7nnam1emW0ge0z7hJabR0+j0PnUxFIwkI5jO3HI5UiuUzuQFe
2bTLA3XnJ7QD08ZKom0rmApbFrmm9BWBRTmt46NlQDy49VODPY4gFuQ/mpaFjaBy
fSNJaOSS/MDuAdPabNEh3l+yCGKtHIbPVIms76PxYf6o0VVxW96/Q25hrvyOJCxn
dVQyyJbQ1jGenu4ViDNrW9ZQfw4aJCPpY7lUQd09BGz2NMKgkrSl8bKSan4lvlF3
ok8BjfIw+pIrTyesPU5tF0YudDxwi8fbIG70iwrpsSt2wVIMa+Nz2lwFT1dV8be7
NARkkkhLWJYAsxsyVfdl+ucNSqhvo8xLITuG8CZnzKf0T2HMKnMNegFx/ipfM7ff
Mx5CjayN5Oy99MWsagYEutUGzCGPAuVpqYpJuuYa3lWbFk2XWihWkAiUwgRqIluE
M6LpO8l3LVXVjN1+6bK1GZpbfLay+E6vy4W38XMuXZSNpyhy6e+XggTPH2xbbwoi
OcAzcojhMaxVGpxm/aXyRxg9zBdrQjtqM/aCN91ri55bvOKxELVi+D/VcZKpd2CR
X/vWcqoGaK/6+vlPWMZSHCJkPa4KBT0aUcnEdeFWx2nmrwdrHvETzCYLAzVBSECV
ZoYH0xTkFr/RI2AOAzx701LSuYbnPoCq+w7TXtjPaooZdYVVgrYuI+j4JOlseFS7
1c9iRiJVPBfnpUNIZdHLw19+k81IJ/FmumiuDhfLS5pwQmtuXkO3DWZDa3UPlV8e
6dmZeP1XGwRLL9VpOKx7NCqZM+CdEt87CXpFFWXdw8tL+3K/2r8w4lHIzBKaVPSS
5uFqXc1vzfP6Qeov31IjeLPE1pWTHNqRPdmvt9Scq9tKS3o18wmLBxOVinOE0cxQ
oddzPd0z5NxNYVayqZORwDdVv6CVXKnrvBSnOFFslZqv1G8/diE5BXxeaAPEMcZE
3lD7MzdoEHK5oL2MXofLWZbNtMkOZLaLqY80zKT1UG3Gs8U44d44aLXO1dBL0HGX
dNfNUaH+IGZf2ccS6OR1RhwIazDZ8qk0XeUwQV588adwC3FUvscVA3eHZa95z4kX
xvHg+ylzRtKRfpSPzB2IVwgV9/rsOg0OmvwhV8+5IQpdcFr+hf2Bn6AVn6H9aX8A
JjycN6KMcHaFa0EUqagGm9tsQLmf/MGCj8sy9am1IbRmFCz5lB5A7P/YLPM2Csjg
-----END RSA PRIVATE KEY-----`;
const certificate = `
-----BEGIN CERTIFICATE-----
MIIDlzCCAn+gAwIBAgIJAO1ymQc33+bWMA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNV
BAYTAkhLMRMwEQYDVQQIDApTb21lLVN0YXRlMRowGAYDVQQKDBFJZGVudGl0eSBQ
cm92aWRlcjEUMBIGA1UECwwLRGV2ZWxvcG1lbnQxDDAKBgNVBAMMA0lEUDAeFw0x
NTA3MDUxODAyMjdaFw0xODA3MDQxODAyMjdaMGIxCzAJBgNVBAYTAkhLMRMwEQYD
VQQIDApTb21lLVN0YXRlMRowGAYDVQQKDBFJZGVudGl0eSBQcm92aWRlcjEUMBIG
A1UECwwLRGV2ZWxvcG1lbnQxDDAKBgNVBAMMA0lEUDCCASIwDQYJKoZIhvcNAQEB
BQADggEPADCCAQoCggEBAODZsWhCe+yG0PalQPTUoD7yko5MTWMCRxJ8hSm2k7mG
3Eg/Y2v0EBdCmTw7iDCevRqUmbmFnq7MROyV4eriJzh0KabAdZf7/k6koghst3ZU
tWOwzshyxkBtWDwGmBpQGTGsKxJ8M1js3aSqNRXBT4OBWM9w2Glt1+8ty30RhYv3
pSF+/HHLH7Ac+vLSIAlokaFW34RWTcJ/8rADuRWlXih4GfnIu0W/ncm5nTSaJiRA
vr3dGDRO/khiXoJdbbOj7dHPULxVGbH9IbPK76TCwLbF7ikIMsPovVbTrpyL6vsb
VUKeEl/5GKppTwp9DLAOeoSYpCYkkDkYKu9TRQjF02MCAwEAAaNQME4wHQYDVR0O
BBYEFP2ut2AQdy6D1dwdwK740IHmbh38MB8GA1UdIwQYMBaAFP2ut2AQdy6D1dwd
wK740IHmbh38MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBANMZUoPN
mHzgja2PYkbvBYMHmpvUkVoiuvQ9cJPlqGTB2CRfG68BNNs/Clz8P7cIrAdkhCUw
i1rSBhDuslGFNrSaIpv6B10FpBuKwef3G7YrPWFNEN6khY7aHNWSTHqKgs1DrGef
2B9hvkrnHWbQVSVXrBFKe1wTCqcgGcOpYoSK7L8C6iX6uIA/uZYnVQ4NgBrizJ0a
zkjdegz3hwO/gt4malEURy8D85/AAVt6PAzhpb9VJUGxSXr/EfntVUEz3L2gUFWW
k1CnZFyz0rIOEt/zPmeAY8BLyd/Tjxm4Y+gwNazKq5y9AJS+m858b/nM4QdCnUE4
yyoWAJDUHiAmvFA=
-----END CERTIFICATE-----
`;
const idpEncryptionKey = `
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: DES-EDE3-CBC,860FDB9F3BE14699
bMpTdWaAEqNciUFQhHYNv1F9N12aqOQd6cFbMozfRnNR19HW6QIPDmEOPSSCaaRy
QCnJhbpcSnaz9pvI7EzeJzdykDmR8Boos+0NSK9qIX0buBO55mfPr7hjx7bLFEVl
kkHk+k9F1rLyjyAGJrVoTNoWjyuMOFUCWR7ZxoYticwM/sL+Rbhn1FsfdkdfhFW0
08OHTouRK33Aifx0A3MWxR0ILvw49E6urtbbIrskEzKzfWQug8gY1TJhI3sbsMsI
1bS5Vg88TvilFFBGn0Yv6GEJjgOrsrKDGKtYGhuBfK4fd4rwnQKKvC6gTKeNXIfV
7Qm1R20LUJXC8zv35pdKoVk+NdS/MGNXJRFgO3Kkp01aVf3n1oo2+AllS02AYyWt
1svHecsRwbibXip8gSQsOtDdpqQrEDyqZlFHXEw/IcJE9vQWEJmpHD5GFhbKtttp
E0B3ZtNl6YcyUz0rSf9zjuMx/wReWdRb6H2WoIqoRS7vAUONDRPt7wvfjtLlDRVi
bc2RTN8yce/57lGnA1n8bxPV5+9VxCJOEipV3io/nrj+uNO8i/0rUpkKdZy8wy2C
Rksoxq4TxwegONz1HQcJVpJu0iBdu7B+BXVjxQQScvMQlOTbua8k+YdaCeZAb83j
JVX89/PFy+Xj7eGyzzBTqz7dV0Xkxq9mpiMYUCoyNL5Iq1jD9Xb5TzVW1Gbh8zCZ
YXjcZEQKeartaBC4/fRWyxqK3gJRX4SJkl4gYMQrPS2pbTzVCO+WLxSwIh3dOZpo
eErXLSrylIv9cE2Xrs0McXAR+hfGrqgtILBWwgbh2NhmUiFfLwUTUxU51eu7QZ2T
V1VFBX0QTmn2kM0JLSSC96mDUzbs6qfURUaXbuffF5cqdUjXgtzZj5SFEbIv4UFS
0DAS+6i/jTGSz7aAp/uofOxhYkCqK/s2Cex2jQbDpcKXKiWzPdULOCjAh3fdCAp0
3ua3fdAI7H8PslSDiPFrcY78OxZaWXzazEiun77WKbzrMloLMP5dpCPlUCOqxbZ0
ykSuo0M7p/UPY34yi3AMHS9grvQQ1DykMPoqKKEheI6nUGcQ1AFcdr307ILWRsPO
T6gHOLXZaR4+UEeYfkTKsjrMUhozx7JIyuLgTXA9TWC+tZ9WZpbJ7i3bpQ+RNwX2
AxQSwc9ZOcNxg8YCbGlJgJHnRVhA202kNT5ORplcRKqaOaO9LK7491gaaShjaspg
4THDnH+HHFORmbgwyO9P74wuw+n6tI40Ia3qzRLVz6sJBQMtLEN+cvNoNi3KYkNj
GJM1iWfSz6PjrEGxbzQZKoFPPiZrVRnVfPhBNyT2OZj+TJii9CaukhmkkA2/AJmS
5XoO3GNIaqOGYV9HLyh1++cn3NhjgFYe/Q3ORCTIg2Ltd8Qr6mYe0LcONQFgiv4c
AUOZtOq05fJDXE74R1JjYHPaQF6uZEbTF98jN9QZIfCEvDdv1nC83MvSwATi0j5S
LvdU/MSPaZ0VKzPc4JPwv72dveEPME6QyswKx9izioJVrQJr36YtmrhDlKR1WBny
ISbutnQPUN5fsaIsgKDIV3T7n6519t6brobcW5bdigmf5ebFeZJ16/lYy6V77UM5
-----END RSA PRIVATE KEY-----
`;
const spEncryptionKey = `
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: DES-EDE3-CBC,860FDB9F3BE14699
bMpTdWaAEqNciUFQhHYNv1F9N12aqOQd6cFbMozfRnNR19HW6QIPDmEOPSSCaaRy
QCnJhbpcSnaz9pvI7EzeJzdykDmR8Boos+0NSK9qIX0buBO55mfPr7hjx7bLFEVl
kkHk+k9F1rLyjyAGJrVoTNoWjyuMOFUCWR7ZxoYticwM/sL+Rbhn1FsfdkdfhFW0
08OHTouRK33Aifx0A3MWxR0ILvw49E6urtbbIrskEzKzfWQug8gY1TJhI3sbsMsI
1bS5Vg88TvilFFBGn0Yv6GEJjgOrsrKDGKtYGhuBfK4fd4rwnQKKvC6gTKeNXIfV
7Qm1R20LUJXC8zv35pdKoVk+NdS/MGNXJRFgO3Kkp01aVf3n1oo2+AllS02AYyWt
1svHecsRwbibXip8gSQsOtDdpqQrEDyqZlFHXEw/IcJE9vQWEJmpHD5GFhbKtttp
E0B3ZtNl6YcyUz0rSf9zjuMx/wReWdRb6H2WoIqoRS7vAUONDRPt7wvfjtLlDRVi
bc2RTN8yce/57lGnA1n8bxPV5+9VxCJOEipV3io/nrj+uNO8i/0rUpkKdZy8wy2C
Rksoxq4TxwegONz1HQcJVpJu0iBdu7B+BXVjxQQScvMQlOTbua8k+YdaCeZAb83j
JVX89/PFy+Xj7eGyzzBTqz7dV0Xkxq9mpiMYUCoyNL5Iq1jD9Xb5TzVW1Gbh8zCZ
YXjcZEQKeartaBC4/fRWyxqK3gJRX4SJkl4gYMQrPS2pbTzVCO+WLxSwIh3dOZpo
eErXLSrylIv9cE2Xrs0McXAR+hfGrqgtILBWwgbh2NhmUiFfLwUTUxU51eu7QZ2T
V1VFBX0QTmn2kM0JLSSC96mDUzbs6qfURUaXbuffF5cqdUjXgtzZj5SFEbIv4UFS
0DAS+6i/jTGSz7aAp/uofOxhYkCqK/s2Cex2jQbDpcKXKiWzPdULOCjAh3fdCAp0
3ua3fdAI7H8PslSDiPFrcY78OxZaWXzazEiun77WKbzrMloLMP5dpCPlUCOqxbZ0
ykSuo0M7p/UPY34yi3AMHS9grvQQ1DykMPoqKKEheI6nUGcQ1AFcdr307ILWRsPO
T6gHOLXZaR4+UEeYfkTKsjrMUhozx7JIyuLgTXA9TWC+tZ9WZpbJ7i3bpQ+RNwX2
AxQSwc9ZOcNxg8YCbGlJgJHnRVhA202kNT5ORplcRKqaOaO9LK7491gaaShjaspg
4THDnH+HHFORmbgwyO9P74wuw+n6tI40Ia3qzRLVz6sJBQMtLEN+cvNoNi3KYkNj
GJM1iWfSz6PjrEGxbzQZKoFPPiZrVRnVfPhBNyT2OZj+TJii9CaukhmkkA2/AJmS
5XoO3GNIaqOGYV9HLyh1++cn3NhjgFYe/Q3ORCTIg2Ltd8Qr6mYe0LcONQFgiv4c
AUOZtOq05fJDXE74R1JjYHPaQF6uZEbTF98jN9QZIfCEvDdv1nC83MvSwATi0j5S
LvdU/MSPaZ0VKzPc4JPwv72dveEPME6QyswKx9izioJVrQJr36YtmrhDlKR1WBny
ISbutnQPUN5fsaIsgKDIV3T7n6519t6brobcW5bdigmf5ebFeZJ16/lYy6V77UM5
-----END RSA PRIVATE KEY-----
`;
const mockIdpSigningCert = `-----BEGIN CERTIFICATE-----
MIIFOjCCAyICCQCqP5DN+xQZDjANBgkqhkiG9w0BAQsFADBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wHhcNMjMxMTE5MTIzNzE3WhcNMzMxMTE2MTIzNzE3WjBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQD5giLoLyED41IHt0RxB/k6x4K0vzAKiGecPyedRNR1oyiv3OYkuG5jgTE2wcPZc7kD1Eg5d6th0BWHy/ovaNS5mkgnOV6jKkMaWW4sCMSnLnaWy0seftPK3O4mNeZpM5e9amj2gXnZvKrK8cqnJ/bsUUQvXxttXNVVmOHWg/t3c2vJ4XuUfph6wIKbrj297ILzuAFRNvAVxeS0tElwepvZ5Wbf7Hc1MORAqTpw/mp8cRjHRzYCA9y6OM4hgVs1gvTJS8WGoMmsdAZHaOnv9vLJvW3jDLQQecOheYIJncWgcESzJFIkmXadorYCEfWhwwBdVphknmeLr4BMpJBclAYaFjYDLIKpMcXYO5k/2r3BgSPlw4oqbxbR5geD05myKYtZ/wNUtku118NjhIfJFulU/kfDcp1rYYkvzgBfqr80wgNps4oQzVr1mnpgHsSTAhXMuZbaTByJRmPqecyvyQqRQcRIN0oTLJNGyzoUf0RkH6DKJ4+7qDhlq4Zhlfso9OFMv9xeONfIrJo5HtTfFZfidkXZqir2ZqwqNlNOMfK5DsYq37x2Gkgqig4nqLpITXyxfnQpL2HsaoFrlctt/OL+Zqba7NT4heYk9GX8qlAS+Ipsv6T2HSANbah55oSS3uvcrDOug2Zq7+GYMLKS1IKUKhwX+wLMxmMwSJQ9ZgFwfQIDAQABMA0GCSqGSIb3DQEBCwUAA4ICAQCkGPZdflocTSXIe5bbehsBn/IPdyb38eH2HaAvWqO2XNcDcq+6/uLc8BVK4JMa3AFS9xtBza7MOXN/lw/Ccb8uJGVNUE31+rTvsJaDtMCQkp+9aG04I1BonEHfSB0ANcTy/Gp+4hKyFCd6x35uyPO7CWX5Z8I87q9LF6Dte3/v1j7VZgDjAi9yHpBJv9Xje33AK1vF+WmEfDUOi8y2B8htVeoyS3owln3ZUbnmJdCmMp2BMRq63ymINwklEaYaNrp1L201bSqNdKZF2sNwROWyDX+WFYgufrnzPYb6HS8gYb4oEZmaG5cBM7Hs730/3BlbHKhxNTy1Io2TVCYcMQD+ieiVg5e5eGTwaPYGuVvY3NVhO8FaYBG7K2NT2hqutdCMaQpGyHEzbbbTY1afhbeMmWWqivRnVJNDv4kgBc2SE8JO82qHikIW9Om0cghC5xwTT+1JTtxxD1KeC1M1IwLzzuuMmwJSKAsv4duDqN+YRIP78J2SlrssqlsmoF8+48e7Vzr7JRT/Ya274P8RpUPNtxTR7WDmZ4tunqXjiBpz6l0uTtVXnj5UBo4HCyRjWJOGf15OCuQX03qz8tKn1IbZUf723qrmSF+cxBwHqpAywqhTSsaLjIXKnQ0UlMov7QWb0a5N07JZMdMSerbHvbXd/z9S1Ssea2+EGuTYuQur3A==
-----END CERTIFICATE-----`;
const unrelatedCertificate = `-----BEGIN CERTIFICATE-----
MIIDATCCAemgAwIBAgIUTDC29D27otkSCpMVYZSZ7135bZcwDQYJKoZIhvcNAQEL
BQAwEDEOMAwGA1UEAwwFVGVzdDIwHhcNMjYwMzI3MTcyNDA4WhcNMzYwMzI0MTcy
NDA4WjAQMQ4wDAYDVQQDDAVUZXN0MjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC
AQoCggEBALXz/zRq88hhlLTJ47MoLdFqwjC9A9HiV6xOdezHhZ+vBaqCXsUcru9N
3FfG6EVtuxSTLQIfgMpxifS4tF2tqkxBcW7JDGIuN02hjeWzlVztyAzISlZJUrGS
kNnE11Br7p4O3OC1OcrpTBi/uBUPBiCFeeQCSIk+pf0h7Y4NUa27oTsZ6Qy0II7A
2m5yqtlfyvxyLKIVIlDt1yCcvvJ2MSdiCcGDfyB/BmL5ow8kaR6bal1w/NRh8pNr
OOjHmx3W+6Qv6g1M/mK7tITlstEEFJyWdzw6yEAZ7jyrHMkJKj9wTgmMxlx/H1d7
st234qgFjm6w3WxA0AwaqNcjiD3SpxkCAwEAAaNTMFEwHQYDVR0OBBYEFPegA1b2
piumrJ5DhMqgauhDrSYpMB8GA1UdIwQYMBaAFPegA1b2piumrJ5DhMqgauhDrSYp
MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAE9imWiQSn1nde7x
HPCZTuu3ydTNl6vT8yDHYUYBAC7kqUuhJ3gFd8x6toolxP2FvVuF4I7ovvRpW5zk
eJ/AGOY4YB5g6K0MUpgy42V5WDXTqLR/62VCH+jQHgIjd0I728FWVoElXHYhupnR
EKG5qVkfs8ySaBr/IV5nTSa0R9IyJX+Sb+qqITcD4CUmtNSwB1XhJq403VFCwiu+
TQNsZdoNl0fm5SR11rDA3IffIxeAvTtZwSJ/hOfBQM1RnM24t6xYX7Oe/2ZPhN7v
epyw0Ikhqk/BFtQCRei+t1HJ9GIu6qnsC7CxrUA80IcxZjeg7N6ua+uctzRWzDhn
kBGIJYs=
-----END CERTIFICATE-----`;
const generateRequestID = () => {
return "_" + randomUUID();
};
interface MockSAMLTemplateOverrides {
audience?: string;
destination?: string;
inResponseTo?: string;
subjectRecipient?: string;
}
const createTemplateCallback =
(
idp: any,
sp: any,
email: string,
overrides: MockSAMLTemplateOverrides = {},
) =>
(template: any) => {
const assertionConsumerServiceUrl =
sp.entityMeta.getAssertionConsumerService(
saml.Constants.wording.binding.post,
);
const nameIDFormat = idp.entitySetting.nameIDFormat;
const selectedNameIDFormat = Array.isArray(nameIDFormat)
? nameIDFormat[0]
: nameIDFormat;
const id = generateRequestID();
const now = new Date();
const fiveMinutesLater = new Date(now.getTime() + 5 * 60 * 1000);
const tagValues = {
ID: id,
AssertionID: generateRequestID(),
Destination: overrides.destination ?? assertionConsumerServiceUrl,
Audience: overrides.audience ?? sp.entityMeta.getEntityID(),
EntityID: sp.entityMeta.getEntityID(),
SubjectRecipient:
overrides.subjectRecipient ?? assertionConsumerServiceUrl,
Issuer: idp.entityMeta.getEntityID(),
IssueInstant: now.toISOString(),
AssertionConsumerServiceURL: assertionConsumerServiceUrl,
StatusCode: "urn:oasis:names:tc:SAML:2.0:status:Success",
ConditionsNotBefore: now.toISOString(),
ConditionsNotOnOrAfter: fiveMinutesLater.toISOString(),
SubjectConfirmationDataNotOnOrAfter: fiveMinutesLater.toISOString(),
NameIDFormat: selectedNameIDFormat,
NameID: email,
InResponseTo: overrides.inResponseTo ?? "null",
AuthnStatement: "",
attrFirstName: "Test",
attrLastName: "User",
attrEmail: "test@email.com",
};
return {
id,
context: saml.SamlLib.replaceTagsByValue(template, tagValues),
};
};
/**
* Verifies a SAML HTTP-Redirect binding signature per SAML 2.0 Bindings §3.4.4.1.
* The signed string is reconstructed from the raw query string (everything before &Signature=).
*/
function verifyRedirectSignature(
rawUrl: string,
spSigningKey: string,
spSigningKeyPass?: string,
): boolean {
const queryStart = rawUrl.indexOf("?");
if (queryStart === -1) return false;
const queryString = rawUrl.substring(queryStart + 1);
const signatureParam = "&Signature=";
const signatureIndex = queryString.indexOf(signatureParam);
if (signatureIndex === -1) return false;
const signedData = queryString.substring(0, signatureIndex);
const signatureValue = decodeURIComponent(
queryString.substring(signatureIndex + signatureParam.length),
);
const cleanKey = spSigningKey
.split("\n")
.map((line) => line.trim())
.join("\n");
const privateKey = createPrivateKey({
key: cleanKey,
format: "pem",
...(spSigningKeyPass ? { passphrase: spSigningKeyPass } : {}),
});
const publicKey = createPublicKey(privateKey);
const verifier = createVerify("RSA-SHA256");
verifier.update(signedData);
return verifier.verify(publicKey, signatureValue, "base64");
}
interface MockIdPOptions {
idpMetadataXml?: string;
spMetadataXml?: string;
isAssertionEncrypted?: boolean;
wantAuthnRequestsSigned?: boolean;
spSigningKey?: string;
spSigningKeyPass?: string;
}
interface MockSAMLResponse {
samlResponse: string;
entityEndpoint?: string;
}
const createMockSAMLIdP = (port: number, options: MockIdPOptions = {}) => {
const app: ExpressApp = express();
let server: ReturnType<typeof createServer> | undefined;
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
const idpMetadataXml = options.idpMetadataXml || idpMetadata;
const idp = saml.IdentityProvider({
metadata: idpMetadataXml,
privateKey: idPk,
isAssertionEncrypted: options.isAssertionEncrypted ?? false,
privateKeyPass: "jXmKf9By6ruLnUdRo90G",
loginResponseTemplate: {
context:
'<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{ID}" Version="2.0" IssueInstant="{IssueInstant}" Destination="{Destination}" InResponseTo="{InResponseTo}"><saml:Issuer>{Issuer}</saml:Issuer><samlp:Status><samlp:StatusCode Value="{StatusCode}"/></samlp:Status><saml:Assertion ID="{AssertionID}" Version="2.0" IssueInstant="{IssueInstant}" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"><saml:Issuer>{Issuer}</saml:Issuer><saml:Subject><saml:NameID Format="{NameIDFormat}">{NameID}</saml:NameID><saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml:SubjectConfirmationData NotOnOrAfter="{SubjectConfirmationDataNotOnOrAfter}" Recipient="{SubjectRecipient}" InResponseTo="{InResponseTo}"/></saml:SubjectConfirmation></saml:Subject><saml:Conditions NotBefore="{ConditionsNotBefore}" NotOnOrAfter="{ConditionsNotOnOrAfter}"><saml:AudienceRestriction><saml:Audience>{Audience}</saml:Audience></saml:AudienceRestriction></saml:Conditions>{AttributeStatement}</saml:Assertion></samlp:Response>',
attributes: [
{
name: "firstName",
valueTag: "firstName",
nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
valueXsiType: "xs:string",
},
{
name: "lastName",
valueTag: "lastName",
nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
valueXsiType: "xs:string",
},
{
name: "email",
valueTag: "email",
nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
valueXsiType: "xs:string",
},
],
},
});
const sp = saml.ServiceProvider({
metadata: options.spMetadataXml ?? spMetadata,
});
const handleIdPRequest = async (
req: ExpressRequest,
res: ExpressResponse,
) => {
try {
if (options.wantAuthnRequestsSigned) {
const { SAMLRequest, SigAlg, Signature } = req.query;
if (!SAMLRequest || !SigAlg || !Signature) {
return res
.status(400)
.json({ error: "Missing required signature parameters" });
}
if (!options.spSigningKey) {
return res
.status(500)
.json({ error: "Mock IdP not configured with SP signing key" });
}
const expectedSigAlg =
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
if (String(SigAlg) !== expectedSigAlg) {
return res.status(400).json({
error: `Unsupported signature algorithm: ${String(SigAlg)}`,
});
}
const isValid = verifyRedirectSignature(
req.originalUrl,
options.spSigningKey,
options.spSigningKeyPass,
);
if (!isValid) {
return res
.status(400)
.json({ error: "Invalid AuthnRequest signature" });
}
}
const emailCase = req.query.emailCase as string;
const emailValue =
emailCase === "mixed" ? "TestUser@Example.com" : "test@email.com";
const user = {
email: emailValue,
emailAddress: emailValue,
famName: "hello world",
};
const queryValue = (value: unknown) =>
typeof value === "string" ? value : undefined;
const idpInitiated = req.query.idpInitiated === "true";
const parsedRequest =
!idpInitiated &&
!options.wantAuthnRequestsSigned &&
typeof req.query.SAMLRequest === "string"
? await idp.parseLoginRequest(sp, "redirect", {
query: req.query,
})
: null;
const requestInfo: RequestInfo = parsedRequest
? {
extract: parsedRequest.extract,
samlContent: parsedRequest.samlContent,
sigAlg: parsedRequest.sigAlg,
}
: { extract: {} };
const requestId = requestInfo.extract.request?.id;
const templateOverrides: MockSAMLTemplateOverrides = {
audience: queryValue(req.query.audience),
destination: queryValue(req.query.destination),
inResponseTo: idpInitiated
? ""
: typeof requestId === "string"
? requestId
: "null",
subjectRecipient: queryValue(req.query.recipient),
};
const { context, entityEndpoint } = (await idp.createLoginResponse(
sp,
requestInfo,
saml.Constants.wording.binding.post,
user,
createTemplateCallback(idp, sp, user.emailAddress, templateOverrides),
)) as { context: string; entityEndpoint?: string };
res.status(200).json({ samlResponse: context, entityEndpoint });
} catch (error) {
res.status(500).json({
error: error instanceof Error ? error.message : String(error),
});
}
};
app.get("/api/sso/saml2/idp/post", handleIdPRequest);
app.get("/api/sso/saml2/idp/redirect", handleIdPRequest);
app.post("/api/sso/saml2/sp/acs", async (req: any, res: any) => {
try {
const parseResult = await sp.parseLoginResponse(
idp,
saml.Constants.wording.binding.post,
req,
);
const { extract } = parseResult;
const { attributes } = extract;
const relayState = req.body.RelayState;
if (relayState) {
return res.status(200).send({ relayState, attributes });
} else {
return res
.status(200)
.send({ extract, message: "RelayState is missing." });
}
} catch (error) {
console.error("Error handling SAML ACS endpoint:", error);
res.status(500).send({ error: "Failed to process SAML response." });
}
});
app.post(
"/api/sso/saml2/sp/acs/:providerId",
async (req: ExpressRequest, res: ExpressResponse) => {
const { SAMLResponse, RelayState } = req.body;
try {
await sp.parseLoginResponse(idp, saml.Constants.wording.binding.post, {
body: { SAMLResponse },
});
res.redirect(302, RelayState || "http://localhost:3000/dashboard");
} catch (error) {
console.error("Error processing SAML callback:", error);
res.status(500).send({ error: "Failed to process SAML response" });
}
},
);
app.get(
"/api/sso/saml2/idp/metadata",
(req: ExpressRequest, res: ExpressResponse) => {
res.type("application/xml");
res.send(idpMetadataXml);
},
);
const start = () =>
new Promise<void>((resolve) => {
app.use(bodyParser.urlencoded({ extended: true }));
server = app.listen(port, () => {
console.log(`Mock SAML IdP running on port ${port}`);
resolve();
});
});
const stop = () =>
new Promise<void>((resolve, reject) => {
app.use(bodyParser.urlencoded({ extended: true }));
server?.close((err) => {
if (err) reject(err);
else resolve();
});
});
const metadataUrl = `http://localhost:${port}/idp/metadata`;
return { start, stop, metadataUrl };
};
// Shared mock SAML IdP for all tests
const sharedMockIdP = createMockSAMLIdP(8081);
beforeAll(async () => {
await sharedMockIdP.start();
});
afterAll(async () => {
await sharedMockIdP.stop();
});
describe("private key normalization", () => {
// samlify >= 2.11 parses keys with native crypto (OpenSSL 3), which rejects
// indented PEM that node-forge tolerated. normalizePem keeps such keys working.
it("loads PEM keys pasted with leading indentation", () => {
const indented = idPk
.split("\n")
.map((line) => ` ${line}`)
.join("\n");
expect(() => createPrivateKey({ key: indented })).toThrow();
expect(() =>
createPrivateKey({ key: normalizePem(indented) }),
).not.toThrow();
});
});
describe("SAML SSO with defaultSSO array", async () => {
const data = {
user: [],
session: [],
verification: [],
account: [],
ssoProvider: [],
};
const memory = memoryAdapter(data);
const ssoOptions = {
defaultSSO: [
{
domain: "localhost:8081",
providerId: "default-saml",
samlConfig: {
issuer: "http://localhost:8081",
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
],
provisionUser: vi
.fn()
.mockImplementation(async ({ user, userInfo, token, provider }) => {
return {
id: "provisioned-user-id",
email: userInfo.email,
name: userInfo.name,
attributes: userInfo.attributes,
};
}),
};
const auth = betterAuth({
database: memory,
baseURL: "http://localhost:3000",
emailAndPassword: {
enabled: true,
},
plugins: [sso(ssoOptions)],
});
it("should use default SAML SSO provider from array when no provider found in database", async () => {
const signInResponse = await auth.api.signInSSO({
body: {
providerId: "default-saml",
callbackURL: "http://localhost:3000/dashboard",
},
});
expect(signInResponse).toEqual({
url: expect.stringContaining("http://localhost:8081"),
redirect: true,
});
});
it("should reject additionalParams when routing to a SAML provider", async () => {
await expect(
auth.api.signInSSO({
body: {
providerId: "default-saml",
callbackURL: "http://localhost:3000/dashboard",
additionalParams: { domain_hint: "contoso.com" },
},
}),
).rejects.toThrow(/additionalParams is not supported for SAML/);
});
it("should fetch sp metadata for a defaultSSO provider", async () => {
const spMetadataRes = await auth.api.spMetadata({
query: {
providerId: "default-saml",
},
});
const spMetadataResValue = await spMetadataRes.text();
expect(spMetadataRes.status).toBe(200);
expect(spMetadataResValue).toBe(spMetadata);
});
});
describe("SAML SSO with signed AuthnRequests", async () => {
const idpMetadataWithSignedRequests = `
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://localhost:8082/api/sso/saml2/idp/metadata">
<md:IDPSSODescriptor WantAuthnRequestsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>MIIFOjCCAyICCQCqP5DN+xQZDjANBgkqhkiG9w0BAQsFADBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wHhcNMjMxMTE5MTIzNzE3WhcNMzMxMTE2MTIzNzE3WjBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQD5giLoLyED41IHt0RxB/k6x4K0vzAKiGecPyedRNR1oyiv3OYkuG5jgTE2wcPZc7kD1Eg5d6th0BWHy/ovaNS5mkgnOV6jKkMaWW4sCMSnLnaWy0seftPK3O4mNeZpM5e9amj2gXnZvKrK8cqnJ/bsUUQvXxttXNVVmOHWg/t3c2vJ4XuUfph6wIKbrj297ILzuAFRNvAVxeS0tElwepvZ5Wbf7Hc1MORAqTpw/mp8cRjHRzYCA9y6OM4hgVs1gvTJS8WGoMmsdAZHaOnv9vLJvW3jDLQQecOheYIJncWgcESzJFIkmXadorYCEfWhwwBdVphknmeLr4BMpJBclAYaFjYDLIKpMcXYO5k/2r3BgSPlw4oqbxbR5geD05myKYtZ/wNUtku118NjhIfJFulU/kfDcp1rYYkvzgBfqr80wgNps4oQzVr1mnpgHsSTAhXMuZbaTByJRmPqecyvyQqRQcRIN0oTLJNGyzoUf0RkH6DKJ4+7qDhlq4Zhlfso9OFMv9xeONfIrJo5HtTfFZfidkXZqir2ZqwqNlNOMfK5DsYq37x2Gkgqig4nqLpITXyxfnQpL2HsaoFrlctt/OL+Zqba7NT4heYk9GX8qlAS+Ipsv6T2HSANbah55oSS3uvcrDOug2Zq7+GYMLKS1IKUKhwX+wLMxmMwSJQ9ZgFwfQIDAQABMA0GCSqGSIb3DQEBCwUAA4ICAQCkGPZdflocTSXIe5bbehsBn/IPdyb38eH2HaAvWqO2XNcDcq+6/uLc8BVK4JMa3AFS9xtBza7MOXN/lw/Ccb8uJGVNUE31+rTvsJaDtMCQkp+9aG04I1BonEHfSB0ANcTy/Gp+4hKyFCd6x35uyPO7CWX5Z8I87q9LF6Dte3/v1j7VZgDjAi9yHpBJv9Xje33AK1vF+WmEfDUOi8y2B8htVeoyS3owln3ZUbnmJdCmMp2BMRq63ymINwklEaYaNrp1L201bSqNdKZF2sNwROWyDX+WFYgufrnzPYb6HS8gYb4oEZmaG5cBM7Hs730/3BlbHKhxNTy1Io2TVCYcMQD+ieiVg5e5eGTwaPYGuVvY3NVhO8FaYBG7K2NT2hqutdCMaQpGyHEzbbbTY1afhbeMmWWqivRnVJNDv4kgBc2SE8JO82qHikIW9Om0cghC5xwTT+1JTtxxD1KeC1M1IwLzzuuMmwJSKAsv4duDqN+YRIP78J2SlrssqlsmoF8+48e7Vzr7JRT/Ya274P8RpUPNtxTR7WDmZ4tunqXjiBpz6l0uTtVXnj5UBo4HCyRjWJOGf15OCuQX03qz8tKn1IbZUf723qrmSF+cxBwHqpAywqhTSsaLjIXKnQ0UlMov7QWb0a5N07JZMdMSerbHvbXd/z9S1Ssea2+EGuTYuQur3A==</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:8082/api/sso/saml2/idp/redirect"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:8082/api/sso/saml2/idp/post"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>
`;
const signedMockIdP = createMockSAMLIdP(8082, {
idpMetadataXml: idpMetadataWithSignedRequests,
wantAuthnRequestsSigned: true,
spSigningKey: idPk,
spSigningKeyPass: "jXmKf9By6ruLnUdRo90G",
});
const data = {
user: [],
session: [],
verification: [],
account: [],
ssoProvider: [],
};
const memory = memoryAdapter(data);
const ssoOptions = {
defaultSSO: [
{
domain: "localhost:8082",
providerId: "signed-saml",
samlConfig: {
issuer: "http://localhost:8082",
entryPoint: "http://localhost:8082/api/sso/saml2/idp/redirect",
cert: certificate,
wantAssertionsSigned: false,
authnRequestsSigned: true,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
privateKey: idPk,
spMetadata: {
privateKey: idPk,
},
idpMetadata: {
metadata: idpMetadataWithSignedRequests,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
],
};
const auth = betterAuth({
database: memory,
baseURL: "http://localhost:3000",
emailAndPassword: {
enabled: true,
},
plugins: [sso(ssoOptions)],
});
beforeAll(async () => {
await signedMockIdP.start();
});
afterAll(async () => {
await signedMockIdP.stop();
});
it("should generate signed AuthnRequest with Signature and SigAlg", async () => {
const signInResponse = await auth.api.signInSSO({
body: {
providerId: "signed-saml",
callbackURL: "http://localhost:3000/dashboard",
},
});
expect(signInResponse).toEqual({
url: expect.stringContaining("http://localhost:8082"),
redirect: true,
});
expect(signInResponse.url).toContain("Signature=");
expect(signInResponse.url).toContain("SigAlg=");
});
it("should include RelayState in the signed URL", async () => {
const signInResponse = await auth.api.signInSSO({
body: {
providerId: "signed-saml",
callbackURL: "http://localhost:3000/dashboard",
},
});
expect(signInResponse.url).toContain("RelayState=");
// RelayState must appear before Signature (it's part of the signed data)
const relayStateIdx = signInResponse.url.indexOf("RelayState=");
const signatureIdx = signInResponse.url.indexOf("Signature=");
expect(relayStateIdx).toBeLessThan(signatureIdx);
});
it("should produce a signature the IdP can verify", async () => {
const signInResponse = await auth.api.signInSSO({
body: {
providerId: "signed-saml",
callbackURL: "http://localhost:3000/dashboard",
},
});
// The mock IdP at port 8082 verifies the AuthnRequest signature.
// If the signature is invalid, it returns 400.
const res = await fetch(signInResponse.url);
const body = await res.json();
expect({ status: res.status, body }).toMatchObject({
status: 200,
body: expect.objectContaining({ samlResponse: expect.any(String) }),
});
});
it("should throw when authnRequestsSigned is true but no privateKey is provided", async () => {
const noKeyAuth = betterAuth({
database: memoryAdapter({
user: [],
session: [],
verification: [],
account: [],
ssoProvider: [],
}),
baseURL: "http://localhost:3000",
emailAndPassword: { enabled: true },
plugins: [
sso({
defaultSSO: [
{
domain: "localhost:8082",
providerId: "no-key-saml",
samlConfig: {
issuer: "http://localhost:8082",
entryPoint: "http://localhost:8082/api/sso/saml2/idp/redirect",
cert: certificate,
authnRequestsSigned: true,
spMetadata: {},
idpMetadata: {
metadata: idpMetadataWithSignedRequests,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
],
}),
],
});
await expect(
noKeyAuth.api.signInSSO({
body: {
providerId: "no-key-saml",
callbackURL: "http://localhost:3000/dashboard",
},
}),
).rejects.toThrow(/privateKey/);
});
});
describe("SAML SSO without signed AuthnRequests", async () => {
const data = {
user: [],
session: [],
verification: [],
account: [],
ssoProvider: [],
};
const memory = memoryAdapter(data);
const ssoOptions = {
defaultSSO: [
{
domain: "localhost:8082",
providerId: "unsigned-saml",
samlConfig: {
issuer: "http://localhost:8082",
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
authnRequestsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
],
};
const auth = betterAuth({
database: memory,
baseURL: "http://localhost:3000",
emailAndPassword: {
enabled: true,
},
plugins: [sso(ssoOptions)],
});
it("should NOT include Signature in URL when authnRequestsSigned is false", async () => {
const signInResponse = await auth.api.signInSSO({
body: {
providerId: "unsigned-saml",
callbackURL: "http://localhost:3000/dashboard",
},
});
expect(signInResponse).toEqual({
url: expect.stringContaining("http://localhost:8081"),
redirect: true,
});
// When authnRequestsSigned is false (default), no Signature should be in the URL
expect(signInResponse.url).not.toContain("Signature=");
expect(signInResponse.url).not.toContain("SigAlg=");
});
});
describe("SAML SSO with idpMetadata but without metadata XML (fallback to top-level config)", async () => {
const data = {
user: [],
session: [],
verification: [],
account: [],
ssoProvider: [],
};
const memory = memoryAdapter(data);
// This tests the fix for signInSSO where IdentityProvider was incorrectly constructed
// when idpMetadata is provided but without a full metadata XML.
// The bug was:
// 1. Using encryptCert instead of signingCert (samlify expects signingCert)
// 2. Not falling back to parsedSamlConfig.issuer when entityID is missing
// 3. Not falling back to parsedSamlConfig.entryPoint when singleSignOnService is missing
const ssoOptions = {
defaultSSO: [
{
domain: "localhost:8083",
providerId: "partial-idp-metadata-saml",
samlConfig: {
issuer: "http://localhost:8083/issuer",
entryPoint: "http://localhost:8081/api/sso/saml2/idp/redirect",
cert: certificate,
wantAssertionsSigned: false,
authnRequestsSigned: false,
spMetadata: {},
// idpMetadata is provided but WITHOUT metadata XML - this triggers the fallback path
// The fix ensures signingCert is used (not encryptCert) and entryPoint/issuer fallbacks work
idpMetadata: {
// No metadata XML provided
// cert could be provided here, but we test fallback to top-level cert
entityID: "http://localhost:8081/custom-entity-id",
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
],
};
const auth = betterAuth({
database: memory,
baseURL: "http://localhost:3000",
emailAndPassword: {
enabled: true,
},
plugins: [sso(ssoOptions)],
});
it("should initiate SAML login using fallback entryPoint when idpMetadata has no metadata XML", async () => {
const signInResponse = await auth.api.signInSSO({
body: {
providerId: "partial-idp-metadata-saml",
callbackURL: "http://localhost:3000/dashboard",
},
});
// The URL should point to the entryPoint from top-level config (fallback)
expect(signInResponse).toEqual({
url: expect.stringContaining(
"http://localhost:8081/api/sso/saml2/idp/redirect",
),
redirect: true,
});
// The URL should contain a SAMLRequest parameter, proving the IdP was constructed correctly
// with signingCert (not encryptCert) - if encryptCert was used, samlify would fail
expect(signInResponse.url).toContain("SAMLRequest=");
});
it("should use idpMetadata.entityID when provided (not fall back to issuer)", async () => {
const signInResponse = await auth.api.signInSSO({
body: {
providerId: "partial-idp-metadata-saml",
callbackURL: "http://localhost:3000/dashboard",
},
});
// The fact that we get a valid SAMLRequest proves the IdentityProvider
// was constructed correctly. The entityID from idpMetadata should be used.
const url = new URL(signInResponse.url);
const samlRequest = url.searchParams.get("SAMLRequest");
expect(samlRequest).toBeTruthy();
});
});
describe("SAML SSO", async () => {
const data = {
user: [],
session: [],
verification: [],
account: [],
ssoProvider: [],
};
const memory = memoryAdapter(data);
const ssoOptions = {
provisionUser: vi
.fn()
.mockImplementation(async ({ user, userInfo, token, provider }) => {
return {
id: "provisioned-user-id",
email: userInfo.email,
name: userInfo.name,
attributes: userInfo.attributes,
};
}),
saml: {
// Disable for the shared instance — InResponseTo validation has
// dedicated tests. Enabling it here would require every test to
// go through the SP-initiated flow to store an AuthnRequest first.
enableInResponseToValidation: false,
},
};
const auth = betterAuth({
database: memory,
baseURL: "http://localhost:3000",
emailAndPassword: {
enabled: true,
},
plugins: [sso(ssoOptions)],
});
const authClient = createAuthClient({
baseURL: "http://localhost:3000",
plugins: [bearer(), ssoClient()],
fetchOptions: {
customFetchImpl: async (url, init) => {
return auth.handler(new Request(url, init));
},
},
});
const testUser = {
email: "test@email.com",
password: "password",
name: "Test User",
};
beforeAll(async () => {
await authClient.signUp.email({
email: testUser.email,
password: testUser.password,
name: testUser.name,
});
});
beforeEach(() => {
data.user = [];
data.session = [];
data.verification = [];
data.account = [];
data.ssoProvider = [];
});
async function getAuthHeaders() {
const headers = new Headers();
await authClient.signUp.email({
email: testUser.email,
password: testUser.password,
name: testUser.name,
});
await authClient.signIn.email(testUser, {
throw: true,
onSuccess: setCookieToHeader(headers),
});
return headers;
}
it("should register a new SAML provider", async () => {
const headers = await getAuthHeaders();
await authClient.signIn.email(testUser, {
throw: true,
onSuccess: setCookieToHeader(headers),
});
const provider = await auth.api.registerSSOProvider({
body: {
providerId: "saml-provider-1",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
privateKey: idpPrivateKey,
privateKeyPass: "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW",
isAssertionEncrypted: true,
encPrivateKey: idpEncryptionKey,
encPrivateKeyPass: "g7hGcRmp8PxT5QeP2q9Ehf1bWe9zTALN",
},
spMetadata: {
metadata: spMetadata,
binding: "post",
privateKey: spPrivateKey,
privateKeyPass: "VHOSp5RUiBcrsjrcAuXFwU1NKCkGA8px",
isAssertionEncrypted: true,
encPrivateKey: spEncryptionKey,
encPrivateKeyPass: "BXFNKpxrsjrCkGA8cAu5wUVHOSpci1RU",
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
expect(provider).toMatchObject({
id: expect.any(String),
issuer: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
cert: expect.any(String),
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
});
});
it("Should fetch sp metadata", async () => {
const headers = await getAuthHeaders();
await authClient.signIn.email(testUser, {
throw: true,
onSuccess: setCookieToHeader(headers),
});
const provider = await auth.api.registerSSOProvider({
body: {
providerId: "saml-provider-1",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
privateKey: idpPrivateKey,
privateKeyPass: "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW",
isAssertionEncrypted: true,
encPrivateKey: idpEncryptionKey,
encPrivateKeyPass: "g7hGcRmp8PxT5QeP2q9Ehf1bWe9zTALN",
},
spMetadata: {
metadata: spMetadata,
binding: "post",
privateKey: spPrivateKey,
privateKeyPass: "VHOSp5RUiBcrsjrcAuXFwU1NKCkGA8px",
isAssertionEncrypted: true,
encPrivateKey: spEncryptionKey,
encPrivateKeyPass: "BXFNKpxrsjrCkGA8cAu5wUVHOSpci1RU",
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
const spMetadataRes = await auth.api.spMetadata({
query: {
providerId: provider.providerId,
},
});
const spMetadataResResValue = await spMetadataRes.text();
expect(spMetadataRes.status).toBe(200);
expect(spMetadataResResValue).toBe(spMetadata);
});
it("Should fetch sp metadata", async () => {
const headers = await getAuthHeaders();
await authClient.signIn.email(testUser, {
throw: true,
onSuccess: setCookieToHeader(headers),
});
const issuer = "http://localhost:8081";
const provider = await auth.api.registerSSOProvider({
body: {
providerId: "saml-provider-1",
issuer: issuer,
domain: issuer,
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
privateKey: idpPrivateKey,
privateKeyPass: "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW",
isAssertionEncrypted: true,
encPrivateKey: idpEncryptionKey,
encPrivateKeyPass: "g7hGcRmp8PxT5QeP2q9Ehf1bWe9zTALN",
},
spMetadata: {
binding: "post",
privateKey: spPrivateKey,
privateKeyPass: "VHOSp5RUiBcrsjrcAuXFwU1NKCkGA8px",
isAssertionEncrypted: true,
encPrivateKey: spEncryptionKey,
encPrivateKeyPass: "BXFNKpxrsjrCkGA8cAu5wUVHOSpci1RU",
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
const spMetadataRes = await auth.api.spMetadata({
query: {
providerId: provider.providerId,
},
});
const spMetadataResResValue = await spMetadataRes.text();
expect(spMetadataRes.status).toBe(200);
expect(spMetadataResResValue).toBeDefined();
expect(spMetadataResResValue).toContain(
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
);
expect(spMetadataResResValue).toContain(
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
);
expect(spMetadataResResValue).toContain(
`<EntityDescriptor entityID="${issuer}"`,
);
expect(spMetadataResResValue).toContain(
`Location="http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-provider-1"`,
);
});
it("should initiate SAML login and handle response", async () => {
const headers = await getAuthHeaders();
await authClient.signIn.email(testUser, {
throw: true,
onSuccess: setCookieToHeader(headers),
});
await auth.api.registerSSOProvider({
body: {
providerId: "saml-provider-1",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
privateKey: idpPrivateKey,
privateKeyPass: "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW",
isAssertionEncrypted: true,
encPrivateKey: idpEncryptionKey,
encPrivateKeyPass: "g7hGcRmp8PxT5QeP2q9Ehf1bWe9zTALN",
},
spMetadata: {
metadata: spMetadata,
binding: "post",
privateKey: spPrivateKey,
privateKeyPass: "VHOSp5RUiBcrsjrcAuXFwU1NKCkGA8px",
isAssertionEncrypted: true,
encPrivateKey: spEncryptionKey,
encPrivateKeyPass: "BXFNKpxrsjrCkGA8cAu5wUVHOSpci1RU",
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
const signInResponse = await auth.api.signInSSO({
body: {
providerId: "saml-provider-1",
callbackURL: "http://localhost:3000/dashboard",
},
});
expect(signInResponse).toEqual({
url: expect.stringContaining("http://localhost:8081"),
redirect: true,
});
let samlResponse: any;
await betterFetch(signInResponse?.url as string, {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
let redirectLocation = "";
await betterFetch(
"http://localhost:8081/api/sso/saml2/sp/acs/saml-provider-1",
{
method: "POST",
redirect: "manual",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
onError: (context) => {
expect(context.response.status).toBe(302);
redirectLocation = context.response.headers.get("location") || "";
},
},
);
expect(redirectLocation).toBe("http://localhost:3000/dashboard");
});
it("should validate response binding after decrypting an encrypted assertion", async () => {
const encryptedIdpMetadata = idpMetadata.replaceAll(
"localhost:8081",
"localhost:8084",
);
const encryptionCertificate = certificate
.replace(/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----/g, "")
.replace(/\s+/g, "");
const encryptedSpMetadata = spMetadata.replace(
/<ds:X509Certificate>[^<]+<\/ds:X509Certificate>/g,
`<ds:X509Certificate>${encryptionCertificate}</ds:X509Certificate>`,
);
const encryptedMockIdP = createMockSAMLIdP(8084, {
idpMetadataXml: encryptedIdpMetadata,
spMetadataXml: encryptedSpMetadata,
isAssertionEncrypted: true,
});
await encryptedMockIdP.start();
try {
const encryptedAuth = betterAuth({
database: memoryAdapter({
user: [],
session: [],
verification: [],
account: [],
ssoProvider: [],
}),
baseURL: "http://localhost:3000",
emailAndPassword: {
enabled: true,
},
plugins: [
sso({
saml: { enableInResponseToValidation: false },
defaultSSO: [
{
domain: "localhost:8084",
providerId: "encrypted-saml-provider",
samlConfig: {
issuer: "http://localhost:8084",
entryPoint: "http://localhost:8084/api/sso/saml2/idp/post",
cert: certificate,
callbackUrl: "http://localhost:8084/dashboard",
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: encryptedIdpMetadata,
privateKey: idpPrivateKey,
privateKeyPass: "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW",
isAssertionEncrypted: true,
encPrivateKey: idpEncryptionKey,
encPrivateKeyPass: "g7hGcRmp8PxT5QeP2q9Ehf1bWe9zTALN",
},
spMetadata: {
metadata: encryptedSpMetadata,
binding: "post",
privateKey: idpPrivateKey,
privateKeyPass: "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW",
isAssertionEncrypted: true,
encPrivateKey: idpPrivateKey,
encPrivateKeyPass: "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW",
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
],
}),
],
});
const signInResponse = await encryptedAuth.api.signInSSO({
body: {
providerId: "encrypted-saml-provider",
callbackURL: "http://localhost:3000/dashboard",
},
});
const relayState =
new URL(signInResponse?.url as string).searchParams.get("RelayState") ??
"";
let samlResponse: MockSAMLResponse | undefined;
await betterFetch(signInResponse?.url as string, {
onSuccess: async (context) => {
samlResponse = (await context.data) as MockSAMLResponse;
},
});
expect(samlResponse).toBeDefined();
const samlResponseXml = Buffer.from(
samlResponse!.samlResponse,
"base64",
).toString("utf8");
expect(samlResponseXml).toContain("EncryptedAssertion");
const response = await encryptedAuth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/encrypted-saml-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse!.samlResponse,
RelayState: relayState,
}),
},
),
);
expect(response.status).toBe(302);
const redirectLocation = response.headers.get("location") || "";
expect(redirectLocation).toBe("http://localhost:3000/dashboard");
} finally {
await encryptedMockIdP.stop();
}
});
it("should not allow creating a provider if limit is set to 0", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ providersLimit: 0 })],
});
const { headers } = await signInWithTestUser();
await expect(
auth.api.registerSSOProvider({
body: {
providerId: "saml-provider-1",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
cert: certificate,
idpMetadata: mockIdentityProviderMetadata,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
spMetadata: {
metadata: spMetadata,
},
},
},
headers,
}),
).rejects.toMatchObject({
status: "FORBIDDEN",
body: { message: "SSO provider registration is disabled" },
});
});
it("should not allow creating a provider if limit is reached", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ providersLimit: 1 })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "saml-provider-1",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
cert: certificate,
idpMetadata: mockIdentityProviderMetadata,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
spMetadata: {
metadata: spMetadata,
},
},
},
headers,
});
await expect(
auth.api.registerSSOProvider({
body: {
providerId: "saml-provider-2",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
cert: certificate,
idpMetadata: mockIdentityProviderMetadata,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
spMetadata: {
metadata: spMetadata,
},
},
},
headers,
}),
).rejects.toMatchObject({
status: "FORBIDDEN",
body: {
message: "You have reached the maximum number of SSO providers",
},
});
});
it("should not allow creating a provider if limit from function is reached", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
sso({
providersLimit: async (user) => {
return user.email === "pro@example.com" ? 2 : 1;
},
}),
],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "saml-provider-1",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
cert: certificate,
idpMetadata: mockIdentityProviderMetadata,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
spMetadata: {
metadata: spMetadata,
},
},
},
headers,
});
await expect(
auth.api.registerSSOProvider({
body: {
providerId: "saml-provider-2",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
cert: certificate,
idpMetadata: mockIdentityProviderMetadata,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
spMetadata: {
metadata: spMetadata,
},
},
},
headers,
}),
).rejects.toMatchObject({
status: "FORBIDDEN",
body: {
message: "You have reached the maximum number of SSO providers",
},
});
});
it("should reject registration when no cert source is provided", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso()],
});
const { headers } = await signInWithTestUser();
await expect(
auth.api.registerSSOProvider({
body: {
providerId: "no-cert-source",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
idpMetadata: mockIdentityProviderMetadata,
spMetadata: {
metadata: spMetadata,
},
},
},
headers,
}),
).rejects.toMatchObject({
status: "BAD_REQUEST",
body: { code: "CERT_SOURCE_MISSING" },
});
});
it.each([
["bad-then-good", [unrelatedCertificate, mockIdpSigningCert]],
["good-then-bad", [mockIdpSigningCert, unrelatedCertificate]],
])(
"should validate SAML response when signing cert matches any in the array (%s)",
async (_name, certs) => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "saml-provider-multi-cert",
issuer: "http://localhost:8081/api/sso/saml2/idp/metadata",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certs,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
entityID: "http://localhost:8081/api/sso/saml2/idp/metadata",
singleSignOnService: [
{
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
Location: "http://localhost:8081/api/sso/saml2/idp/post",
},
{
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
Location: "http://localhost:8081/api/sso/saml2/idp/post",
},
],
},
spMetadata: {
metadata: spMetadata,
binding: "post",
privateKey: spPrivateKey,
privateKeyPass: "VHOSp5RUiBcrsjrcAuXFwU1NKCkGA8px",
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
const signInResponse = await auth.api.signInSSO({
body: {
providerId: "saml-provider-multi-cert",
callbackURL: "http://localhost:3000/dashboard",
},
returnHeaders: true,
});
expect(signInResponse.response).toEqual({
url: expect.stringContaining("http://localhost:8081"),
redirect: true,
});
let samlResponse: any;
await betterFetch(signInResponse.response?.url as string, {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
const samlRedirectUrl = new URL(signInResponse.response?.url as string);
const callbackResponse = await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse.samlResponse,
RelayState: samlRedirectUrl.searchParams.get("RelayState") ?? "",
},
headers: {
Cookie: signInResponse.headers.get("set-cookie") ?? "",
},
params: {
providerId: "saml-provider-multi-cert",
},
asResponse: true,
});
expect(callbackResponse.headers.get("location")).toContain("dashboard");
},
);
it("should not allow creating a provider with duplicate providerId", async () => {
const headers = await getAuthHeaders();
await authClient.signIn.email(testUser, {
throw: true,
onSuccess: setCookieToHeader(headers),
});
await auth.api.registerSSOProvider({
body: {
providerId: "duplicate-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
cert: certificate,
idpMetadata: mockIdentityProviderMetadata,
spMetadata: {
metadata: spMetadata,
},
},
},
headers,
});
await expect(
auth.api.registerSSOProvider({
body: {
providerId: "duplicate-provider",
issuer: "http://localhost:8082",
domain: "http://localhost:8082",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
cert: certificate,
idpMetadata: mockIdentityProviderMetadata,
spMetadata: {
metadata: spMetadata,
},
},
},
headers,
}),
).rejects.toMatchObject({
status: "UNPROCESSABLE_ENTITY",
body: {
message: "SSO provider with this providerId already exists",
},
});
});
it("should initiate SAML login and validate RelayState", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "saml-provider-1",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
const response = await auth.api.signInSSO({
body: {
providerId: "saml-provider-1",
callbackURL: "http://localhost:3000/dashboard",
},
returnHeaders: true,
});
const signInResponse = response.response;
expect(signInResponse).toEqual({
url: expect.stringContaining("http://localhost:8081"),
redirect: true,
});
let samlResponse: any;
await betterFetch(signInResponse?.url, {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
const samlRedirectUrl = new URL(signInResponse?.url);
const callbackResponse = await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse.samlResponse,
RelayState: samlRedirectUrl.searchParams.get("RelayState") ?? "",
},
headers: {
Cookie: response.headers.get("set-cookie") ?? "",
},
params: {
providerId: "saml-provider-1",
},
asResponse: true,
});
expect(callbackResponse.headers.get("location")).toContain("dashboard");
});
it("should reject an invalid RelayState even when InResponseTo validation is disabled", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "saml-provider-1",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
const response = await auth.api.signInSSO({
body: {
providerId: "saml-provider-1",
callbackURL: "http://localhost:3000/dashboard",
},
returnHeaders: true,
});
const signInResponse = response.response;
expect(signInResponse).toEqual({
url: expect.stringContaining("http://localhost:8081"),
redirect: true,
});
let samlResponse: any;
await betterFetch(signInResponse?.url, {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
const callbackResponse = await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse.samlResponse,
RelayState: "not-the-right-relay-state",
},
headers: {
Cookie: response.headers.get("set-cookie") ?? "",
},
params: {
providerId: "saml-provider-1",
},
asResponse: true,
});
expect(callbackResponse.status).toBe(302);
const location = callbackResponse.headers.get("location") ?? "";
expect(location).toContain("error=invalid_state");
expect(location).toContain(
"error_description=invalid_or_expired_relay_state",
);
});
it("should initiate SAML login and signup user when disableImplicitSignUp is true but requestSignup is explicitly enabled", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
sso({
disableImplicitSignUp: true,
saml: { enableInResponseToValidation: false },
}),
],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "saml-provider-1",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
const response = await auth.api.signInSSO({
body: {
providerId: "saml-provider-1",
callbackURL: "http://localhost:3000/dashboard",
requestSignUp: true,
},
returnHeaders: true,
});
const signInResponse = response.response;
expect(signInResponse).toEqual({
url: expect.stringContaining("http://localhost:8081"),
redirect: true,
});
let samlResponse: any;
await betterFetch(signInResponse?.url, {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
const samlRedirectUrl = new URL(signInResponse?.url);
const callbackResponse = await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse.samlResponse,
RelayState: samlRedirectUrl.searchParams.get("RelayState") ?? "",
},
headers: {
Cookie: response.headers.get("set-cookie") ?? "",
},
params: {
providerId: "saml-provider-1",
},
asResponse: true,
});
expect(callbackResponse.headers.get("location")).toContain("dashboard");
});
it("should reject SAML sign-in when disableImplicitSignUp is true and user doesn't exist", async () => {
const { auth: authWithDisabledSignUp, signInWithTestUser } =
await getTestInstance({
plugins: [
sso({
disableImplicitSignUp: true,
saml: { enableInResponseToValidation: false },
}),
],
});
const { headers } = await signInWithTestUser();
// Register SAML provider
await authWithDisabledSignUp.api.registerSSOProvider({
body: {
providerId: "saml-test-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers: headers,
});
// Identity Provider-initiated: Get SAML response directly from IdP
// The mock IdP will return test@email.com, which doesn't exist in the DB
let samlResponse: any;
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
const response = await authWithDisabledSignUp.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-test-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
expect(response.status).toBe(302);
const redirectLocation = response.headers.get("location") || "";
expect(redirectLocation).toContain("error=signup_disabled");
});
it("should reject SAML ACS (IdP-initiated) when disableImplicitSignUp is true and user doesn't exist", async () => {
const { auth: authWithDisabledSignUp, signInWithTestUser } =
await getTestInstance({
plugins: [
sso({
disableImplicitSignUp: true,
saml: { enableInResponseToValidation: false },
}),
],
});
const { headers } = await signInWithTestUser();
await authWithDisabledSignUp.api.registerSSOProvider({
body: {
providerId: "saml-acs-test-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers: headers,
});
let samlResponse: any;
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
const response = await authWithDisabledSignUp.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-acs-test-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
expect(response.status).toBe(302);
const redirectLocation = response.headers.get("location") || "";
expect(redirectLocation).toContain("error=signup_disabled");
});
it("rejects a SAML assertion whose audience does not match this SP", async () => {
const { auth: authAudience, signInWithTestUser } = await getTestInstance({
plugins: [sso()],
});
const { headers } = await signInWithTestUser();
const acsUrl =
"http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-audience-provider";
await authAudience.api.registerSSOProvider({
body: {
providerId: "saml-audience-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?source=saml",
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
const idpResponseUrl = new URL(
"http://localhost:8081/api/sso/saml2/idp/post",
);
idpResponseUrl.searchParams.set(
"audience",
"http://other.example.com/saml/metadata",
);
idpResponseUrl.searchParams.set("destination", acsUrl);
idpResponseUrl.searchParams.set("recipient", acsUrl);
let samlResponse: MockSAMLResponse | undefined;
await betterFetch(idpResponseUrl.toString(), {
onSuccess: async (context) => {
samlResponse = (await context.data) as MockSAMLResponse;
},
});
expect(samlResponse).toBeDefined();
const response = await authAudience.handler(
new Request(acsUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse!.samlResponse,
}),
}),
);
expect(response.status).toBe(302);
const redirectLocation = response.headers.get("location") || "";
const redirectUrl = new URL(redirectLocation);
expect(redirectLocation).toContain("error=invalid_saml_response");
expect(redirectLocation).toContain("source=saml");
expect(redirectLocation).toContain("error_description=");
expect(redirectUrl.searchParams.get("error_description")).toContain(
"audience does not match",
);
});
it("should deny account linking when provider is not trusted and domain is not verified", async () => {
const { auth: authUntrusted, signInWithTestUser } = await getTestInstance({
account: {
accountLinking: {
enabled: true,
trustedProviders: [],
},
},
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await authUntrusted.api.registerSSOProvider({
body: {
providerId: "untrusted-saml-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
const ctx = await authUntrusted.$context;
await ctx.adapter.create({
model: "user",
data: {
id: "existing-user-id",
email: "test@email.com",
name: "Existing User",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
},
});
let samlResponse: any;
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
const response = await authUntrusted.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/untrusted-saml-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
expect(response.status).toBe(302);
const redirectLocation = response.headers.get("location") || "";
expect(redirectLocation).toContain("error=account_not_linked");
});
// SSO trust must come from verified domain ownership, never from a name
// match against the global `trustedProviders` list — otherwise a
// user-registered SSO provider named after a trusted provider could inherit
// that trust. Registering such a colliding id is now rejected outright.
it("should reject registering an SSO provider whose id collides with a trustedProviders entry", async () => {
const { auth: authWithTrusted, signInWithTestUser } = await getTestInstance(
{
account: {
accountLinking: {
enabled: true,
trustedProviders: ["trusted-saml-provider"],
},
},
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
},
);
const { headers } = await signInWithTestUser();
const response = await authWithTrusted.api.registerSSOProvider({
body: {
providerId: "trusted-saml-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
asResponse: true,
});
expect(response.status).toBe(422);
});
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,
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/sp/acs/strict-saml-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
expect(response.status).toBe(302);
const redirectLocation = response.headers.get("location") || "";
expect(redirectLocation).toContain("error=invalid_saml_response");
});
it("should allow unsolicited SAML response when allowIdpInitiated is explicitly true", 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,
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/sp/acs/permissive-saml-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
expect(response.status).toBe(302);
const redirectLocation = response.headers.get("location") || "";
expect(redirectLocation).not.toContain("error=unsolicited_response");
});
it("should skip InResponseTo validation when explicitly disabled", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
sso({
saml: {
enableInResponseToValidation: false,
},
}),
],
});
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,
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/sp/acs/legacy-saml-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
expect(response.status).toBe(302);
const redirectLocation = response.headers.get("location") || "";
expect(redirectLocation).not.toContain("error=");
});
it("should enable InResponseTo validation by default", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
// No saml config — default behavior
plugins: [
sso({
saml: {
allowIdpInitiated: false,
},
}),
],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "saml-provider",
issuer: "http://localhost:8081",
domain: "example.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
audience: "http://localhost:3000",
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
binding: "post",
},
},
},
headers,
});
// Unsolicited response (no prior AuthnRequest) should be rejected
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/sp/acs/saml-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
expect(response.status).toBe(302);
const redirectLocation = response.headers.get("location") || "";
// The mock IdP response includes InResponseTo, but no matching
// AuthnRequest was stored — so it's rejected as unknown request ID,
// not as unsolicited (which would require InResponseTo to be absent).
expect(redirectLocation).toContain("error=invalid_saml_response");
});
it("should use verification table for InResponseTo validation", async () => {
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,
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/sp/acs/db-fallback-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
// The mock response has InResponseTo but no matching stored request
expect(response.status).toBe(302);
const redirectLocation = response.headers.get("location") || "";
expect(redirectLocation).toContain("error=invalid_saml_response");
});
/**
* @see https://github.com/better-auth/better-auth/issues/8607
*/
it("should reject SAML response with mismatched audience restriction", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
sso({
saml: {
enableInResponseToValidation: false,
},
}),
],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "audience-mismatch-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
audience: "https://wrong-audience.example.com",
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
binding: "post",
},
},
},
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/sp/acs/audience-mismatch-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
expect(response.status).toBe(302);
const redirectLocation = response.headers.get("location") || "";
expect(redirectLocation).toContain(
"error=invalid_saml_response&error_description=Audience+mismatch",
);
});
it("should accept SAML response when audience matches configured value", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
sso({
saml: {
enableInResponseToValidation: false,
},
}),
],
});
const { headers } = await signInWithTestUser();
// The mock IdP's SP entity ID is the audience it sends in assertions
// Configure the provider's audience to match it
await auth.api.registerSSOProvider({
body: {
providerId: "audience-match-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
audience: "http://localhost:3001/api/sso/saml2/sp/metadata",
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
binding: "post",
},
},
},
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/sp/acs/audience-match-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
// Should not redirect to an error — the audience matches
expect(response.status).toBe(302);
const redirectLocation = response.headers.get("location") || "";
expect(redirectLocation).not.toContain("error=");
});
/**
* @see https://github.com/better-auth/better-auth/issues/7777
*/
it("should correctly parse verification-ID-based RelayState on ACS route (SP-initiated)", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "saml-acs-relay-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
// SP-initiated: signInSSO returns a URL with a RelayState verification ID
const signInRes = await auth.api.signInSSO({
body: {
providerId: "saml-acs-relay-provider",
callbackURL: "http://localhost:3000/dashboard",
},
returnHeaders: true,
});
const signInResponse = signInRes.response;
expect(signInResponse).toEqual({
url: expect.stringContaining("http://localhost:8081"),
redirect: true,
});
const samlRedirectUrl = new URL(signInResponse?.url);
const relayStateParam = samlRedirectUrl.searchParams.get("RelayState");
// RelayState should be a verification ID, not a raw URL
expect(relayStateParam).toBeTruthy();
expect(relayStateParam).not.toContain("http");
let samlResponse: any;
await betterFetch(signInResponse?.url, {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
// POST to the ACS endpoint with the verification-ID-based RelayState
const acsResponse = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-acs-relay-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Cookie: signInRes.headers.get("set-cookie") ?? "",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
RelayState: relayStateParam!,
}),
},
),
);
expect(acsResponse.status).toBe(302);
const acsRedirectLocation = acsResponse.headers.get("location") || "";
// Must redirect to the callbackURL from the relay state, not to the verification ID
expect(acsRedirectLocation).toContain("dashboard");
expect(acsRedirectLocation).not.toContain("error");
});
it("should redirect to signIn callbackURL when relay_state cookie is missing on callback route (cross-site POST)", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "saml-provider",
issuer: "http://localhost:8081",
domain: "example.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
binding: "post",
},
},
},
headers,
});
const signInRes = await auth.api.signInSSO({
body: {
providerId: "saml-provider",
callbackURL: "/dashboard",
},
returnHeaders: true,
});
const signInResponse = signInRes.response;
const samlRedirectUrl = new URL(signInResponse?.url);
const relayStateParam = samlRedirectUrl.searchParams.get("RelayState");
expect(relayStateParam).toBeTruthy();
let samlResponse: any;
await betterFetch(signInResponse?.url, {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
// IdP POSTs back without relay_state cookie (SameSite=Lax blocks cross-site POST cookies)
const callbackResponse = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
RelayState: relayStateParam!,
}),
},
),
);
expect(callbackResponse.status).toBe(302);
const redirectLocation = callbackResponse.headers.get("location") || "";
expect(redirectLocation).toContain("/dashboard");
expect(redirectLocation).not.toContain("/sso/saml2/sp/acs/");
expect(redirectLocation).not.toContain("error");
});
it("should redirect to signIn callbackURL when relay_state cookie is missing on ACS route (cross-site POST)", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "saml-provider",
issuer: "http://localhost:8081",
domain: "example.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
binding: "post",
},
},
},
headers,
});
const signInRes = await auth.api.signInSSO({
body: {
providerId: "saml-provider",
callbackURL: "/dashboard",
},
returnHeaders: true,
});
const signInResponse = signInRes.response;
const samlRedirectUrl = new URL(signInResponse?.url);
const relayStateParam = samlRedirectUrl.searchParams.get("RelayState");
expect(relayStateParam).toBeTruthy();
let samlResponse: any;
await betterFetch(signInResponse?.url, {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
const acsResponse = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
RelayState: relayStateParam!,
}),
},
),
);
expect(acsResponse.status).toBe(302);
const redirectLocation = acsResponse.headers.get("location") || "";
expect(redirectLocation).toContain("/dashboard");
expect(redirectLocation).not.toContain("/sso/saml2/sp/acs/");
expect(redirectLocation).not.toContain("error");
});
/**
* @see https://github.com/better-auth/better-auth/issues/7777
*/
it("should fallback to baseURL on ACS route when RelayState is invalid", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso()],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "saml-acs-bad-relay-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
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;
},
});
// POST with a garbage RelayState - should fallback to baseURL
const acsResponse = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-acs-bad-relay-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
RelayState: "not-a-valid-relay-state",
}),
},
),
);
expect(acsResponse.status).toBe(302);
const location = acsResponse.headers.get("location") || "";
// Should redirect to baseURL with error, not the garbage RelayState
expect(location).toContain("http://localhost:3000");
expect(location).not.toContain("not-a-valid-relay-state");
});
});
describe("SAML SSO with custom fields", () => {
const ssoOptions = {
modelName: "sso_provider",
fields: {
issuer: "the_issuer",
oidcConfig: "oidc_config",
samlConfig: "saml_config",
userId: "user_id",
providerId: "provider_id",
organizationId: "organization_id",
domain: "the_domain",
},
};
const data = {
user: [],
session: [],
verification: [],
account: [],
sso_provider: [],
};
const memory = memoryAdapter(data);
const auth = betterAuth({
database: memory,
baseURL: "http://localhost:3000",
emailAndPassword: {
enabled: true,
},
plugins: [sso(ssoOptions)],
});
const authClient = createAuthClient({
baseURL: "http://localhost:3000",
plugins: [bearer(), ssoClient()],
fetchOptions: {
customFetchImpl: async (url, init) => {
return auth.handler(new Request(url, init));
},
},
});
const testUser = {
email: "test@email.com",
password: "password",
name: "Test User",
};
beforeAll(async () => {
await authClient.signUp.email({
email: testUser.email,
password: testUser.password,
name: testUser.name,
});
});
beforeEach(() => {
data.user = [];
data.session = [];
data.verification = [];
data.account = [];
data.sso_provider = [];
});
async function getAuthHeaders() {
const headers = new Headers();
await authClient.signUp.email({
email: testUser.email,
password: testUser.password,
name: testUser.name,
});
await authClient.signIn.email(testUser, {
throw: true,
onSuccess: setCookieToHeader(headers),
});
return headers;
}
it("should register a new SAML provider", async () => {
const headers = await getAuthHeaders();
const provider = await auth.api.registerSSOProvider({
body: {
providerId: "saml-provider-1",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
privateKey: idpPrivateKey,
privateKeyPass: "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW",
isAssertionEncrypted: true,
encPrivateKey: idpEncryptionKey,
encPrivateKeyPass: "g7hGcRmp8PxT5QeP2q9Ehf1bWe9zTALN",
},
spMetadata: {
metadata: spMetadata,
binding: "post",
privateKey: spPrivateKey,
privateKeyPass: "VHOSp5RUiBcrsjrcAuXFwU1NKCkGA8px",
isAssertionEncrypted: true,
encPrivateKey: spEncryptionKey,
encPrivateKeyPass: "BXFNKpxrsjrCkGA8cAu5wUVHOSpci1RU",
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
expect(provider).toMatchObject({
id: expect.any(String),
issuer: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
cert: expect.any(String),
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
});
});
});
import { safeJsonParse } from "./utils";
describe("safeJsonParse", () => {
it("returns object as-is when value is already an object", () => {
const obj = { a: 1, nested: { b: 2 } };
const result = safeJsonParse<typeof obj>(obj);
expect(result).toBe(obj); // same reference
expect(result).toEqual({ a: 1, nested: { b: 2 } });
});
it("parses stringified JSON when value is a string", () => {
const json = '{"a":1,"nested":{"b":2}}';
const result = safeJsonParse<{ a: number; nested: { b: number } }>(json);
expect(result).toEqual({ a: 1, nested: { b: 2 } });
});
it("returns null for null input", () => {
const result = safeJsonParse<{ a: number }>(null);
expect(result).toBeNull();
});
it("returns null for undefined input", () => {
const result = safeJsonParse<{ a: number }>(undefined);
expect(result).toBeNull();
});
it("throws error for invalid JSON string", () => {
expect(() => safeJsonParse<{ a: number }>("not valid json")).toThrow(
"Failed to parse JSON",
);
});
it("handles empty object", () => {
const obj = {};
const result = safeJsonParse<typeof obj>(obj);
expect(result).toBe(obj);
});
it("handles empty string JSON", () => {
const result = safeJsonParse<Record<string, never>>("{}");
expect(result).toEqual({});
});
});
describe("SSO Provider Config Parsing", () => {
/**
* @see https://github.com/better-auth/better-auth/issues/10329
*/
it("returns parsed SAML config and avoids [object Object] in response", async () => {
const data = {
user: [] as any[],
session: [] as any[],
verification: [] as any[],
account: [] as any[],
ssoProvider: [] as any[],
};
const memory = memoryAdapter(data);
const auth = betterAuth({
database: memory,
baseURL: "http://localhost:3000",
emailAndPassword: { enabled: true },
plugins: [sso()],
});
const authClient = createAuthClient({
baseURL: "http://localhost:3000",
plugins: [bearer(), ssoClient()],
fetchOptions: {
customFetchImpl: async (url, init) =>
auth.handler(new Request(url, init)),
},
});
const headers = new Headers();
await authClient.signUp.email({
email: "test@example.com",
password: "password123",
name: "Test User",
});
await authClient.signIn.email(
{ email: "test@example.com", password: "password123" },
{ onSuccess: setCookieToHeader(headers) },
);
const provider = await auth.api.registerSSOProvider({
body: {
providerId: "saml-config-provider",
issuer: "http://localhost:8081",
domain: "example.com",
samlConfig: {
entryPoint: "http://localhost:8081/sso",
cert: "test-cert",
idpMetadata: mockIdentityProviderMetadata,
callbackUrl: "http://localhost:3000/callback",
idpInitiatedCallbackUrl: "/dashboard",
spMetadata: {
entityID: "test-entity",
},
},
},
headers,
});
expect(provider.samlConfig).toBeDefined();
expect(typeof provider.samlConfig).toBe("object");
expect(provider.samlConfig?.entryPoint).toBe("http://localhost:8081/sso");
expect(provider.samlConfig?.cert).toBe("test-cert");
expect(provider.samlConfig?.idpInitiatedCallbackUrl).toBe("/dashboard");
const serialized = JSON.stringify(provider.samlConfig);
expect(serialized).not.toContain("[object Object]");
expect(provider.samlConfig?.spMetadata?.entityID).toBe("test-entity");
});
it("returns parsed OIDC config and avoids [object Object] in response", async () => {
const { OAuth2Server } = await import("oauth2-mock-server");
const oidcServer = new OAuth2Server();
await oidcServer.issuer.keys.generate("RS256");
await oidcServer.start(8082, "localhost");
try {
const data = {
user: [] as any[],
session: [] as any[],
verification: [] as any[],
account: [] as any[],
ssoProvider: [] as any[],
};
const memory = memoryAdapter(data);
const auth = betterAuth({
database: memory,
trustedOrigins: ["http://localhost:8082"],
baseURL: "http://localhost:3000",
emailAndPassword: { enabled: true },
plugins: [sso()],
});
const authClient = createAuthClient({
baseURL: "http://localhost:3000",
plugins: [bearer(), ssoClient()],
fetchOptions: {
customFetchImpl: async (url, init) =>
auth.handler(new Request(url, init)),
},
});
const headers = new Headers();
await authClient.signUp.email({
email: "test@example.com",
password: "password123",
name: "Test User",
});
await authClient.signIn.email(
{ email: "test@example.com", password: "password123" },
{ onSuccess: setCookieToHeader(headers) },
);
const provider = await auth.api.registerSSOProvider({
body: {
providerId: "oidc-config-provider",
issuer: oidcServer.issuer.url!,
domain: "example.com",
oidcConfig: {
clientId: "test-client",
clientSecret: "test-secret",
tokenEndpointAuthentication: "client_secret_basic",
mapping: {
email: "email",
name: "name",
},
},
},
headers,
});
expect(provider.oidcConfig).toBeDefined();
expect(typeof provider.oidcConfig).toBe("object");
expect(provider.oidcConfig?.clientId).toBe("test-client");
expect(provider.oidcConfig?.clientSecret).toBe("test-secret");
const serialized = JSON.stringify(provider.oidcConfig);
expect(serialized).not.toContain("[object Object]");
expect(provider.oidcConfig?.mapping).toEqual({
email: "email",
name: "name",
});
} finally {
await oidcServer.stop().catch(() => {});
}
});
});
describe("SAML SSO - IdP Initiated Flow", () => {
it("should handle IdP-initiated flow with GET after POST redirect", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "idp-initiated-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl.replace(
"/idp/metadata",
"/idp/post",
),
cert: certificate,
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:
| { samlResponse: string; entityEndpoint?: string }
| undefined;
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
onSuccess: async (context) => {
samlResponse = context.data as {
samlResponse: string;
entityEndpoint?: string;
};
},
});
if (!samlResponse?.samlResponse) {
throw new Error("Failed to get SAML response from mock IdP");
}
const postResponse = await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse.samlResponse,
},
params: {
providerId: "idp-initiated-provider",
},
asResponse: true,
});
expect(postResponse).toBeInstanceOf(Response);
expect(postResponse.status).toBe(302);
const redirectLocation = postResponse.headers.get("location");
expect(redirectLocation).toBe("http://localhost:3000");
const cookieHeader = postResponse.headers.get("set-cookie");
const getResponse = await auth.api.acsEndpoint({
method: "GET",
query: {
RelayState: "http://localhost:3000/dashboard",
},
params: {
providerId: "idp-initiated-provider",
},
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
asResponse: true,
});
expect(getResponse).toBeInstanceOf(Response);
expect(getResponse.status).toBe(302);
const getRedirectLocation = getResponse.headers.get("location");
expect(getRedirectLocation).toBe("http://localhost:3000/dashboard");
});
it("should reject direct GET request without session", async () => {
const { auth } = await getTestInstance({
plugins: [sso()],
});
const getResponse = await auth.api
.acsEndpoint({
method: "GET",
params: {
providerId: "test-provider",
},
asResponse: true,
})
.catch((e) => {
if (e instanceof APIError && e.status === "FOUND") {
return new Response(null, {
status: e.statusCode,
headers: e.headers || new Headers(),
});
}
throw e;
});
expect(getResponse).toBeInstanceOf(Response);
expect(getResponse.status).toBe(302);
const redirectLocation = getResponse.headers.get("location");
expect(redirectLocation).toContain("/error");
expect(redirectLocation).toContain("error=invalid_request");
});
it("should redirect to baseURL after ACS, not back to the ACS route", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
const callbackRouteUrl =
"http://localhost:3000/api/auth/sso/saml2/sp/acs/loop-test-provider";
await auth.api.registerSSOProvider({
body: {
providerId: "loop-test-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl.replace(
"/idp/metadata",
"/idp/post",
),
cert: certificate,
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:
| { samlResponse: string; entityEndpoint?: string }
| undefined;
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
onSuccess: async (context) => {
samlResponse = context.data as {
samlResponse: string;
entityEndpoint?: string;
};
},
});
if (!samlResponse?.samlResponse) {
throw new Error("Failed to get SAML response from mock IdP");
}
const postResponse = await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse.samlResponse,
},
params: {
providerId: "loop-test-provider",
},
asResponse: true,
});
expect(postResponse).toBeInstanceOf(Response);
expect(postResponse.status).toBe(302);
const redirectLocation = postResponse.headers.get("location");
expect(redirectLocation).not.toBe(callbackRouteUrl);
expect(redirectLocation).toBe("http://localhost:3000");
});
it("should handle GET request with RelayState in query", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "relaystate-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl.replace(
"/idp/metadata",
"/idp/post",
),
cert: certificate,
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:
| { samlResponse: string; entityEndpoint?: string }
| undefined;
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
onSuccess: async (context) => {
samlResponse = context.data as {
samlResponse: string;
entityEndpoint?: string;
};
},
});
if (!samlResponse?.samlResponse) {
throw new Error("Failed to get SAML response from mock IdP");
}
const postResponse = await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse.samlResponse,
},
params: {
providerId: "relaystate-provider",
},
asResponse: true,
});
const cookieHeader = postResponse.headers.get("set-cookie");
const getResponse = await auth.api.acsEndpoint({
method: "GET",
query: {
RelayState: "http://localhost:3000/custom-path",
},
params: {
providerId: "relaystate-provider",
},
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
asResponse: true,
});
expect(getResponse).toBeInstanceOf(Response);
expect(getResponse.status).toBe(302);
const redirectLocation = getResponse.headers.get("location");
expect(redirectLocation).toBe("http://localhost:3000/custom-path");
});
it("should handle GET request when POST redirects to callback URL (original issue scenario)", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
const callbackRouteUrl =
"http://localhost:3000/api/auth/sso/saml2/sp/acs/issue-6615-provider";
await auth.api.registerSSOProvider({
body: {
providerId: "issue-6615-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl.replace(
"/idp/metadata",
"/idp/post",
),
cert: certificate,
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:
| { samlResponse: string; entityEndpoint?: string }
| undefined;
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
onSuccess: async (context) => {
samlResponse = context.data as {
samlResponse: string;
entityEndpoint?: string;
};
},
});
if (!samlResponse?.samlResponse) {
throw new Error("Failed to get SAML response from mock IdP");
}
const postResponse = await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse.samlResponse,
},
params: {
providerId: "issue-6615-provider",
},
asResponse: true,
});
expect(postResponse).toBeInstanceOf(Response);
expect(postResponse.status).toBe(302);
const postRedirectLocation = postResponse.headers.get("location");
expect(postRedirectLocation).not.toBe(callbackRouteUrl);
expect(postRedirectLocation).toBe("http://localhost:3000");
const cookieHeader = postResponse.headers.get("set-cookie");
const getResponse = await auth.api.acsEndpoint({
method: "GET",
params: {
providerId: "issue-6615-provider",
},
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
asResponse: true,
});
expect(getResponse).toBeInstanceOf(Response);
expect(getResponse.status).toBe(302);
const getRedirectLocation = getResponse.headers.get("location");
expect(getRedirectLocation).toBe("http://localhost:3000");
});
it("should prevent open redirect with malicious RelayState URL", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "open-redirect-test-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl.replace(
"/idp/metadata",
"/idp/post",
),
cert: certificate,
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:
| { samlResponse: string; entityEndpoint?: string }
| undefined;
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
onSuccess: async (context) => {
samlResponse = context.data as {
samlResponse: string;
entityEndpoint?: string;
};
},
});
if (!samlResponse?.samlResponse) {
throw new Error("Failed to get SAML response from mock IdP");
}
// Test POST with malicious RelayState - a supplied RelayState must be a
// valid signed state, so a raw value is rejected outright.
const postResponse = await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse.samlResponse,
RelayState: "https://evil.com/phishing",
},
params: {
providerId: "open-redirect-test-provider",
},
asResponse: true,
});
expect(postResponse).toBeInstanceOf(Response);
expect(postResponse.status).toBe(302);
const postRedirectLocation = postResponse.headers.get("location") || "";
// Should NOT redirect to evil.com - the invalid RelayState is rejected
expect(postRedirectLocation).not.toContain("evil.com");
expect(postRedirectLocation).toContain("error=invalid_state");
});
it("should prevent open redirect via GET with malicious RelayState", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "open-redirect-get-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl.replace(
"/idp/metadata",
"/idp/post",
),
cert: certificate,
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:
| { samlResponse: string; entityEndpoint?: string }
| undefined;
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
onSuccess: async (context) => {
samlResponse = context.data as {
samlResponse: string;
entityEndpoint?: string;
};
},
});
if (!samlResponse?.samlResponse) {
throw new Error("Failed to get SAML response from mock IdP");
}
// First do POST to establish session
const postResponse = await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse.samlResponse,
},
params: {
providerId: "open-redirect-get-provider",
},
asResponse: true,
});
const cookieHeader = postResponse.headers.get("set-cookie");
// Test GET with malicious RelayState in query params
const getResponse = await auth.api.acsEndpoint({
method: "GET",
query: {
RelayState: "https://evil.com/steal-cookies",
},
params: {
providerId: "open-redirect-get-provider",
},
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
asResponse: true,
});
expect(getResponse).toBeInstanceOf(Response);
expect(getResponse.status).toBe(302);
const getRedirectLocation = getResponse.headers.get("location");
// Should NOT redirect to evil.com
expect(getRedirectLocation).not.toContain("evil.com");
expect(getRedirectLocation).toBe("http://localhost:3000");
});
it("should reject a raw relative-path RelayState instead of trusting it", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "relative-path-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl.replace(
"/idp/metadata",
"/idp/post",
),
cert: certificate,
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:
| { samlResponse: string; entityEndpoint?: string }
| undefined;
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
onSuccess: async (context) => {
samlResponse = context.data as {
samlResponse: string;
entityEndpoint?: string;
};
},
});
if (!samlResponse?.samlResponse) {
throw new Error("Failed to get SAML response from mock IdP");
}
const postResponse = await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse.samlResponse,
RelayState: "/dashboard/settings",
},
params: {
providerId: "relative-path-provider",
},
asResponse: true,
});
expect(postResponse).toBeInstanceOf(Response);
expect(postResponse.status).toBe(302);
const redirectLocation = postResponse.headers.get("location") || "";
expect(redirectLocation).toContain("error=invalid_state");
});
it("should block protocol-relative URL attacks (//evil.com)", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "protocol-relative-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl.replace(
"/idp/metadata",
"/idp/post",
),
cert: certificate,
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:
| { samlResponse: string; entityEndpoint?: string }
| undefined;
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
onSuccess: async (context) => {
samlResponse = context.data as {
samlResponse: string;
entityEndpoint?: string;
};
},
});
if (!samlResponse?.samlResponse) {
throw new Error("Failed to get SAML response from mock IdP");
}
// Test POST with protocol-relative URL - a supplied RelayState must be a
// valid signed state, so a raw value is rejected outright.
const postResponse = await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse.samlResponse,
RelayState: "//evil.com/phishing",
},
params: {
providerId: "protocol-relative-provider",
},
asResponse: true,
});
expect(postResponse).toBeInstanceOf(Response);
expect(postResponse.status).toBe(302);
const redirectLocation = postResponse.headers.get("location") || "";
// Should NOT redirect to evil.com - the invalid RelayState is rejected
expect(redirectLocation).not.toContain("evil.com");
expect(redirectLocation).toContain("error=invalid_state");
});
});
describe("SAML SSO - Timestamp Validation", () => {
describe("Valid assertions within time window", () => {
it("should accept assertion with current NotBefore and future NotOnOrAfter", () => {
const now = new Date();
const fiveMinutesFromNow = new Date(Date.now() + 5 * 60 * 1000);
expect(() =>
validateSAMLTimestamp({
notBefore: now.toISOString(),
notOnOrAfter: fiveMinutesFromNow.toISOString(),
}),
).not.toThrow();
});
it("should accept assertion within clock skew tolerance (expired 2 min ago with 5 min skew)", () => {
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000).toISOString();
expect(() =>
validateSAMLTimestamp({ notOnOrAfter: twoMinutesAgo }),
).not.toThrow();
});
it("should accept assertion with NotBefore slightly in future (within clock skew)", () => {
const twoMinutesFromNow = new Date(
Date.now() + 2 * 60 * 1000,
).toISOString();
expect(() =>
validateSAMLTimestamp({ notBefore: twoMinutesFromNow }),
).not.toThrow();
});
});
describe("NotBefore validation (future-dated assertions)", () => {
it("should reject assertion with NotBefore too far in future (beyond clock skew)", () => {
const tenMinutesFromNow = new Date(
Date.now() + 10 * 60 * 1000,
).toISOString();
expect(() =>
validateSAMLTimestamp({ notBefore: tenMinutesFromNow }),
).toThrow("SAML assertion is not yet valid");
});
it("should reject with custom strict clock skew (1 second)", () => {
const threeSecondsFromNow = new Date(Date.now() + 3 * 1000).toISOString();
expect(() =>
validateSAMLTimestamp(
{ notBefore: threeSecondsFromNow },
{ clockSkew: 1000 },
),
).toThrow("SAML assertion is not yet valid");
});
});
describe("NotOnOrAfter validation (expired assertions)", () => {
it("should reject expired assertion (NotOnOrAfter in past beyond clock skew)", () => {
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
expect(() =>
validateSAMLTimestamp({ notOnOrAfter: tenMinutesAgo }),
).toThrow("SAML assertion has expired");
});
it("should reject with custom strict clock skew (1 second)", () => {
const threeSecondsAgo = new Date(Date.now() - 3 * 1000).toISOString();
expect(() =>
validateSAMLTimestamp(
{ notOnOrAfter: threeSecondsAgo },
{ clockSkew: 1000 },
),
).toThrow("SAML assertion has expired");
});
});
describe("Boundary conditions (exactly at window edges)", () => {
const FIXED_TIME = new Date("2024-01-15T12:00:00.000Z").getTime();
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(FIXED_TIME);
});
afterEach(() => {
vi.useRealTimers();
});
it("should accept assertion expiring exactly at clock skew boundary", () => {
const exactlyAtBoundary = new Date(
FIXED_TIME - DEFAULT_CLOCK_SKEW_MS,
).toISOString();
expect(() =>
validateSAMLTimestamp({ notOnOrAfter: exactlyAtBoundary }),
).not.toThrow();
});
it("should reject assertion expiring 1ms beyond clock skew boundary", () => {
const justPastBoundary = new Date(
FIXED_TIME - DEFAULT_CLOCK_SKEW_MS - 1,
).toISOString();
expect(() =>
validateSAMLTimestamp({ notOnOrAfter: justPastBoundary }),
).toThrow("SAML assertion has expired");
});
it("should accept assertion with NotBefore exactly at clock skew boundary", () => {
const exactlyAtBoundary = new Date(
FIXED_TIME + DEFAULT_CLOCK_SKEW_MS,
).toISOString();
expect(() =>
validateSAMLTimestamp({ notBefore: exactlyAtBoundary }),
).not.toThrow();
});
it("should reject assertion with NotBefore 1ms beyond clock skew boundary", () => {
const justPastBoundary = new Date(
FIXED_TIME + DEFAULT_CLOCK_SKEW_MS + 1,
).toISOString();
expect(() =>
validateSAMLTimestamp({ notBefore: justPastBoundary }),
).toThrow("SAML assertion is not yet valid");
});
});
describe("Missing timestamps behavior", () => {
it("should accept missing timestamps when requireTimestamps is false (default)", () => {
expect(() =>
validateSAMLTimestamp(undefined, { requireTimestamps: false }),
).not.toThrow();
});
it("should accept empty conditions when requireTimestamps is false", () => {
expect(() =>
validateSAMLTimestamp({}, { requireTimestamps: false }),
).not.toThrow();
});
it("should reject missing timestamps when requireTimestamps is true", () => {
expect(() =>
validateSAMLTimestamp(undefined, { requireTimestamps: true }),
).toThrow("SAML assertion missing required timestamp conditions");
});
it("should reject empty conditions when requireTimestamps is true", () => {
expect(() =>
validateSAMLTimestamp({}, { requireTimestamps: true }),
).toThrow("SAML assertion missing required timestamp conditions");
});
it("should accept assertions with only NotBefore (valid)", () => {
const now = new Date().toISOString();
expect(() => validateSAMLTimestamp({ notBefore: now })).not.toThrow();
});
it("should accept assertions with only NotOnOrAfter (valid, in future)", () => {
const future = new Date(Date.now() + 10 * 60 * 1000).toISOString();
expect(() =>
validateSAMLTimestamp({ notOnOrAfter: future }),
).not.toThrow();
});
});
describe("Custom clock skew configuration", () => {
it("should use custom clockSkew when provided", () => {
const twoSecondsAgo = new Date(Date.now() - 2 * 1000).toISOString();
expect(() =>
validateSAMLTimestamp(
{ notOnOrAfter: twoSecondsAgo },
{ clockSkew: 1000 },
),
).toThrow("SAML assertion has expired");
expect(() =>
validateSAMLTimestamp(
{ notOnOrAfter: twoSecondsAgo },
{ clockSkew: 5 * 60 * 1000 },
),
).not.toThrow();
});
it("should use default 5 minute clock skew when not specified", () => {
const fourMinutesAgo = new Date(Date.now() - 4 * 60 * 1000).toISOString();
expect(() =>
validateSAMLTimestamp({ notOnOrAfter: fourMinutesAgo }),
).not.toThrow();
const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000).toISOString();
expect(() =>
validateSAMLTimestamp({ notOnOrAfter: sixMinutesAgo }),
).toThrow("SAML assertion has expired");
});
});
describe("Malformed timestamp handling", () => {
it("should reject malformed NotBefore timestamp", () => {
expect(() =>
validateSAMLTimestamp({ notBefore: "not-a-valid-date" }),
).toThrow("SAML assertion has invalid NotBefore timestamp");
});
it("should reject malformed NotOnOrAfter timestamp", () => {
expect(() =>
validateSAMLTimestamp({ notOnOrAfter: "invalid-timestamp" }),
).toThrow("SAML assertion has invalid NotOnOrAfter timestamp");
});
it("should treat empty string timestamps as missing (falsy values)", () => {
expect(() => validateSAMLTimestamp({ notBefore: "" })).not.toThrow();
expect(() => validateSAMLTimestamp({ notOnOrAfter: "" })).not.toThrow();
});
it("should reject garbage data in timestamps", () => {
expect(() =>
validateSAMLTimestamp({
notBefore: "abc123xyz",
notOnOrAfter: "!@#$%^&*()",
}),
).toThrow("SAML assertion has invalid NotBefore timestamp");
});
it("should accept valid ISO 8601 timestamps", () => {
const now = new Date();
const future = new Date(Date.now() + 10 * 60 * 1000);
expect(() =>
validateSAMLTimestamp({
notBefore: now.toISOString(),
notOnOrAfter: future.toISOString(),
}),
).not.toThrow();
});
});
});
describe("SAML ACS Origin Check Bypass", () => {
describe("Positive: SAML endpoints allow external IdP origins", () => {
it("should allow SAML callback POST from external IdP origin", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
// Register SAML provider with full config
await auth.api.registerSSOProvider({
body: {
providerId: "origin-bypass-callback",
issuer: "http://localhost:8081",
domain: "origin-bypass.com",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
cert: certificate,
idpMetadata: mockIdentityProviderMetadata,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
spMetadata: {
metadata: spMetadata,
},
},
},
headers,
});
// POST to callback with external Origin header (simulating IdP POST)
// Origin check should be bypassed for SAML callback endpoints
const callbackRes = await auth.handler(
new Request(
"http://localhost:8081/api/auth/sso/saml2/sp/acs/origin-bypass-callback",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Origin: "http://external-idp.example.com", // External IdP origin - would normally be blocked
Cookie: headers.get("cookie") || "",
},
body: new URLSearchParams({
SAMLResponse: Buffer.from("<fake-saml-response/>").toString(
"base64",
),
RelayState: "",
}).toString(),
},
),
);
// Should NOT return 403 Forbidden (origin check bypassed)
// May return other errors (400, 500) due to invalid SAML response, but NOT origin rejection
expect(callbackRes.status).not.toBe(403);
});
it("should allow ACS endpoint POST from external IdP origin", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
// Register SAML provider with full config
await auth.api.registerSSOProvider({
body: {
providerId: "origin-bypass-acs",
issuer: "http://localhost:8081",
domain: "origin-bypass-acs.com",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
cert: certificate,
idpMetadata: mockIdentityProviderMetadata,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
spMetadata: {
metadata: spMetadata,
},
},
},
headers,
});
// POST to ACS with external Origin header
const acsRes = await auth.handler(
new Request(
"http://localhost:8081/api/auth/sso/saml2/sp/acs/origin-bypass-acs",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Origin: "http://idp.external.com", // External IdP origin
Cookie: headers.get("cookie") || "",
},
body: new URLSearchParams({
SAMLResponse: Buffer.from("<fake-saml-response/>").toString(
"base64",
),
}).toString(),
},
),
);
// Should NOT return 403 Forbidden
expect(acsRes.status).not.toBe(403);
});
});
describe("Negative: Non-SAML endpoints remain protected", () => {
it("should block POST to sign-up with untrusted origin when origin check is enabled", async () => {
const { auth } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
advanced: {
disableCSRFCheck: false,
disableOriginCheck: false,
},
});
// Origin check applies when cookies are present and check is enabled
const signUpRes = await auth.handler(
new Request("http://localhost:8081/api/auth/sign-up/email", {
method: "POST",
headers: {
"Content-Type": "application/json",
Origin: "http://attacker.com",
Cookie: "better-auth.session_token=fake-session",
},
body: JSON.stringify({
email: "victim@example.com",
password: "password123",
name: "Victim",
}),
}),
);
expect(signUpRes.status).toBe(403);
});
});
describe("Edge cases", () => {
it("should allow GET requests to SAML metadata regardless of origin", async () => {
const { auth } = await getTestInstance({
plugins: [sso()],
});
// GET requests always bypass origin check
const metadataRes = await auth.handler(
new Request("http://localhost:8081/api/auth/sso/saml2/sp/metadata", {
method: "GET",
headers: {
Origin: "http://any-origin.com",
},
}),
);
expect(metadataRes.status).not.toBe(403);
});
it("should not redirect to malicious RelayState URLs", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso()],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "relay-security-test",
issuer: "http://localhost:8081",
domain: "relay-security.com",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
cert: certificate,
idpMetadata: mockIdentityProviderMetadata,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
spMetadata: {
metadata: spMetadata,
},
},
},
headers,
});
// Even with origin bypass, malicious RelayState should be rejected
const callbackRes = await auth.handler(
new Request(
"http://localhost:8081/api/auth/sso/saml2/sp/acs/relay-security-test",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Origin: "http://idp.example.com",
},
body: new URLSearchParams({
SAMLResponse: Buffer.from("<fake-saml-response/>").toString(
"base64",
),
RelayState: "http://malicious-site.com/steal-token",
}).toString(),
},
),
);
// Should NOT redirect to malicious URL
if (callbackRes.status === 302) {
const location = callbackRes.headers.get("Location");
expect(location).not.toContain("malicious-site.com");
}
});
});
});
describe("SAML Response Security", () => {
it("should reject forged/unsigned SAML responses", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso()],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "security-test-provider",
issuer: "http://localhost:8081",
domain: "security-test.com",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
cert: certificate,
idpMetadata: mockIdentityProviderMetadata,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
spMetadata: {
metadata: spMetadata,
},
},
},
headers,
});
const forgedSAMLResponse = `
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
<saml:Assertion>
<saml:Subject>
<saml2:NameID xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">attacker@evil.com</saml2:NameID>
</saml:Subject>
</saml:Assertion>
</samlp:Response>
`;
const callbackRes = await auth.handler(
new Request(
"http://localhost:8081/api/auth/sso/saml2/sp/acs/security-test-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: Buffer.from(forgedSAMLResponse).toString("base64"),
RelayState: "",
}).toString(),
},
),
);
expect(callbackRes.status).toBe(302);
const location = callbackRes.headers.get("location") || "";
expect(location).toContain("error=");
});
it("should reject SAML response with tampered nameID", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso()],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "tamper-test-provider",
issuer: "http://localhost:8081",
domain: "tamper-test.com",
samlConfig: {
entryPoint: sharedMockIdP.metadataUrl,
cert: certificate,
idpMetadata: mockIdentityProviderMetadata,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
spMetadata: {
metadata: spMetadata,
},
},
},
headers,
});
const tamperedResponse = `<?xml version="1.0"?>
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
<saml2:NameID xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">admin@victim.com</saml2:NameID>
</samlp:Response>`;
const callbackRes = await auth.handler(
new Request(
"http://localhost:8081/api/auth/sso/saml2/sp/acs/tamper-test-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: Buffer.from(tamperedResponse).toString("base64"),
RelayState: "",
}).toString(),
},
),
);
expect(callbackRes.status).toBe(302);
const location = callbackRes.headers.get("location") || "";
expect(location).toContain("error=");
});
});
describe("SAML SSO - Size Limit Validation", () => {
it("should export default size limit constants", async () => {
const { DEFAULT_MAX_SAML_RESPONSE_SIZE, DEFAULT_MAX_SAML_METADATA_SIZE } =
await import("./constants");
expect(DEFAULT_MAX_SAML_RESPONSE_SIZE).toBe(256 * 1024);
expect(DEFAULT_MAX_SAML_METADATA_SIZE).toBe(100 * 1024);
});
});
describe("SAML SSO - Assertion Replay Protection", () => {
it("should reject replayed SAML assertion (same assertion submitted twice)", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "replay-test-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
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 firstResponse = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/replay-test-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
expect(firstResponse.status).toBe(302);
const firstLocation = firstResponse.headers.get("location") || "";
expect(firstLocation).not.toContain("error");
const replayResponse = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/replay-test-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
expect(replayResponse.status).toBe(302);
const replayLocation = replayResponse.headers.get("location") || "";
expect(replayLocation).toContain("error=replay_detected");
});
it("should reject replayed SAML assertion on ACS endpoint", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "acs-replay-test-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
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 firstResponse = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/acs-replay-test-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
expect(firstResponse.status).toBe(302);
const firstLocation = firstResponse.headers.get("location") || "";
expect(firstLocation).not.toContain("error");
const replayResponse = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/acs-replay-test-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
expect(replayResponse.status).toBe(302);
const replayLocation = replayResponse.headers.get("location") || "";
expect(replayLocation).toContain("error=replay_detected");
});
it("should reject cross-endpoint replay (callback → ACS)", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "cross-endpoint-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
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 callbackResponse = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/cross-endpoint-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
expect(callbackResponse.status).toBe(302);
expect(callbackResponse.headers.get("location")).not.toContain("error");
const acsReplayResponse = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/cross-endpoint-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
expect(acsReplayResponse.status).toBe(302);
const acsLocation = acsReplayResponse.headers.get("location") || "";
expect(acsLocation).toContain("error=replay_detected");
});
it("should issue only one session when the same SP-initiated assertion is submitted concurrently", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso()],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "concurrent-replay-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
const ctx = await auth.$context;
const { AUTHN_REQUEST_KEY_PREFIX } = await import("./constants");
const provider = await findSAMLProvider(
"concurrent-replay-provider",
undefined,
ctx.adapter,
);
expect(provider).not.toBeNull();
const providerReference = await computeSSOProviderReference(provider!);
// The mock IdP echoes a fixed `InResponseTo`, so seed the matching
// AuthnRequest record directly. Both concurrent submissions then race to
// consume this single stored request; atomic consumption must let exactly
// one through and leave the other with no request to match.
const inResponseTo = "null";
await ctx.internalAdapter.createVerificationValue({
identifier: `${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
value: JSON.stringify({
id: inResponseTo,
providerId: "concurrent-replay-provider",
providerReference,
createdAt: Date.now(),
expiresAt: Date.now() + 5 * 60 * 1000,
}),
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
});
let samlResponse: any;
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
const submit = () =>
auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/concurrent-replay-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
const [first, second] = await Promise.all([submit(), submit()]);
// Both must redirect; a non-redirect failure leaves an empty location
// that would otherwise be miscounted as a success below.
expect(first.status).toBe(302);
expect(second.status).toBe(302);
const locations = [first, second].map(
(res) => res.headers.get("location") || "",
);
const succeeded = locations.filter((loc) => !loc.includes("error"));
const failed = locations.filter((loc) => loc.includes("error"));
expect(succeeded).toHaveLength(1);
expect(failed).toHaveLength(1);
});
it("should issue only one session when the same assertion is submitted concurrently without InResponseTo validation", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "concurrent-assertion-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
// The shared mock IdP emits a literal `InResponseTo="null"` value. Disable
// that gate here so the assertion-replay tombstone is the only duplicate
// guard under test.
let samlResponse: any;
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
const submit = () =>
auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/concurrent-assertion-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
const [first, second] = await Promise.all([submit(), submit()]);
expect(first.status).toBe(302);
expect(second.status).toBe(302);
const locations = [first, second].map(
(res) => res.headers.get("location") || "",
);
const succeeded = locations.filter((loc) => loc && !loc.includes("error"));
const replayed = locations.filter((loc) =>
loc.includes("error=replay_detected"),
);
expect(succeeded).toHaveLength(1);
expect(replayed).toHaveLength(1);
});
});
describe("SAML SSO - Single Assertion Validation", () => {
it("should reject SAML response with multiple assertions on callback endpoint", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "multi-assertion-callback-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
const multiAssertionResponse = `
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
<saml2p:Status>
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</saml2p:Status>
<saml2:Assertion ID="assertion-1">
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
<saml2:Subject>
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">legitimate@example.com</saml2:NameID>
</saml2:Subject>
</saml2:Assertion>
<saml2:Assertion ID="assertion-2">
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
<saml2:Subject>
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker@evil.com</saml2:NameID>
</saml2:Subject>
</saml2:Assertion>
</saml2p:Response>
`;
const encodedResponse = Buffer.from(multiAssertionResponse).toString(
"base64",
);
const response = await auth.api.acsEndpoint({
body: {
SAMLResponse: encodedResponse,
RelayState: "http://localhost:3000/dashboard",
},
params: {
providerId: "multi-assertion-callback-provider",
},
asResponse: true,
});
expect(response.status).toBe(302);
const location = response.headers.get("location") || "";
expect(location).toContain("error=saml_multiple_assertions");
});
it("should reject SAML response with multiple assertions on ACS endpoint", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "multi-assertion-acs-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
const multiAssertionResponse = `
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
<saml2p:Status>
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</saml2p:Status>
<saml2:Assertion ID="assertion-1">
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
<saml2:Subject>
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">legitimate@example.com</saml2:NameID>
</saml2:Subject>
</saml2:Assertion>
<saml2:Assertion ID="assertion-2">
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
<saml2:Subject>
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker@evil.com</saml2:NameID>
</saml2:Subject>
</saml2:Assertion>
</saml2p:Response>
`;
const encodedResponse = Buffer.from(multiAssertionResponse).toString(
"base64",
);
const response = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/multi-assertion-acs-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: encodedResponse,
RelayState: "http://localhost:3000/dashboard",
}),
},
),
);
// ACS endpoint translates structural errors into browser-friendly redirects
expect(response.status).toBe(302);
const location = response.headers.get("location") || "";
expect(location).toContain("error=saml_multiple_assertions");
});
it("should reject SAML response with no assertions", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso()],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "no-assertion-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
const noAssertionResponse = `
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
<saml2p:Status>
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</saml2p:Status>
</saml2p:Response>
`;
const encodedResponse = Buffer.from(noAssertionResponse).toString("base64");
const response = await auth.api.acsEndpoint({
body: {
SAMLResponse: encodedResponse,
RelayState: "http://localhost:3000/dashboard",
},
params: {
providerId: "no-assertion-provider",
},
asResponse: true,
});
expect(response.status).toBe(302);
const location = response.headers.get("location") || "";
expect(location).toContain("error=saml_no_assertion");
});
it("should reject SAML response with XSW-style assertion injection in Extensions", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso()],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "xsw-injection-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
const xswInjectionResponse = `
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
<saml2p:Status>
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</saml2p:Status>
<saml2p:Extensions>
<saml2:Assertion ID="injected-assertion">
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
<saml2:Subject>
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker@evil.com</saml2:NameID>
</saml2:Subject>
</saml2:Assertion>
</saml2p:Extensions>
<saml2:Assertion ID="legitimate-assertion">
<saml2:Issuer>http://localhost:8081</saml2:Issuer>
<saml2:Subject>
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">user@example.com</saml2:NameID>
</saml2:Subject>
</saml2:Assertion>
</saml2p:Response>
`;
const encodedResponse =
Buffer.from(xswInjectionResponse).toString("base64");
const response = await auth.api.acsEndpoint({
body: {
SAMLResponse: encodedResponse,
RelayState: "http://localhost:3000/dashboard",
},
params: {
providerId: "xsw-injection-provider",
},
asResponse: true,
});
expect(response.status).toBe(302);
const location = response.headers.get("location") || "";
expect(location).toContain("error=saml_multiple_assertions");
});
it("should accept valid SAML response with exactly one assertion", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "single-assertion-provider",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
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/sp/acs/single-assertion-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
}),
},
),
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).not.toContain("error");
});
it("should normalize email to lowercase in SAML authentication to prevent duplicate creation", async () => {
const { auth, client, signInWithTestUser, db } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "email-case-provider",
issuer: "http://localhost:8081",
domain: "example.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
mapping: {
email: "nameID",
name: "displayName",
},
},
},
headers,
});
let samlResponse1: { samlResponse: string } | undefined;
await betterFetch(
"http://localhost:8081/api/sso/saml2/idp/post?emailCase=mixed",
{
onSuccess: async (context) => {
samlResponse1 = context.data as { samlResponse: string };
},
},
);
expect(samlResponse1?.samlResponse).toBeDefined();
const firstCallbackResponse = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/email-case-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse1!.samlResponse,
}),
},
),
);
expect(firstCallbackResponse.status).toBe(302);
expect(firstCallbackResponse.headers.get("location")).not.toContain(
"error",
);
const firstCookies = parseSetCookieHeader(
firstCallbackResponse.headers.get("set-cookie") ?? "",
);
const firstSessionToken = firstCookies.get(
"better-auth.session_token",
)?.value;
expect(firstSessionToken).toBeDefined();
const firstSession = await client.getSession({
fetchOptions: {
headers: {
Cookie: `better-auth.session_token=${firstSessionToken}`,
},
},
});
expect(firstSession.data?.user.email).toBe("testuser@example.com");
const firstUserId = firstSession.data?.user.id;
expect(firstUserId).toBeDefined();
let samlResponse2: { samlResponse: string } | undefined;
await betterFetch(
"http://localhost:8081/api/sso/saml2/idp/post?emailCase=mixed",
{
onSuccess: async (context) => {
samlResponse2 = context.data as { samlResponse: string };
},
},
);
const secondCallbackResponse = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/email-case-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse2!.samlResponse,
}),
},
),
);
expect(secondCallbackResponse.status).toBe(302);
expect(secondCallbackResponse.headers.get("location")).not.toContain(
"error",
);
const secondCookies = parseSetCookieHeader(
secondCallbackResponse.headers.get("set-cookie") ?? "",
);
const secondSessionToken = secondCookies.get(
"better-auth.session_token",
)?.value;
expect(secondSessionToken).toBeDefined();
const secondSession = await client.getSession({
fetchOptions: {
headers: {
Cookie: `better-auth.session_token=${secondSessionToken}`,
},
},
});
expect(secondSession.data?.user.id).toBe(firstUserId);
expect(secondSession.data?.user.email).toBe("testuser@example.com");
const users = (await db.findMany({ model: "user" })) as {
email: string;
}[];
const samlUsers = users.filter((u) => u.email === "testuser@example.com");
expect(samlUsers).toHaveLength(1);
});
});
describe("SAML Single Logout (SLO)", () => {
const sloIdpMetadata = `
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://localhost:8081/api/sso/saml2/idp/metadata">
<md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>${certificate}</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:8081/api/sso/saml2/idp/slo"/>
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:8081/api/sso/saml2/idp/slo"/>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:8081/api/sso/saml2/idp/post"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>
`;
describe("SLO disabled", () => {
it("should return error when enableSingleLogout is not enabled", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso()],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "slo-disabled-test",
issuer: "http://localhost:8081",
domain: "slo-disabled.com",
samlConfig: {
entryPoint: "http://localhost:8081/sso",
cert: certificate,
idpMetadata: mockIdentityProviderMetadata,
spMetadata: {
metadata: spMetadata,
},
},
},
headers,
});
const sloRes = await auth.handler(
new Request(
"http://localhost:8081/api/auth/sso/saml2/sp/slo/slo-disabled-test",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLRequest: Buffer.from("<LogoutRequest/>").toString("base64"),
}).toString(),
},
),
);
expect(sloRes.status).toBe(400);
const body = await sloRes.json();
expect(body.message).toContain("Single Logout is not enabled");
});
});
describe("SLO enabled - error cases", () => {
it("should return error when provider not found", async () => {
const { auth } = await getTestInstance({
plugins: [
sso({
saml: {
enableSingleLogout: true,
},
}),
],
});
const sloRes = await auth.handler(
new Request(
"http://localhost:8081/api/auth/sso/saml2/sp/slo/nonexistent-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLRequest: Buffer.from("<LogoutRequest/>").toString("base64"),
}).toString(),
},
),
);
expect(sloRes.status).toBe(404);
const body = await sloRes.json();
expect(body.message).toContain("SAML provider not found");
});
it("should allow SLO POST from external IdP origin (CSRF bypass)", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
sso({
saml: {
enableSingleLogout: true,
},
}),
],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "slo-origin-bypass",
issuer: "http://localhost:8081",
domain: "slo-origin.com",
samlConfig: {
entryPoint: "http://localhost:8081/sso",
cert: certificate,
idpMetadata: mockIdentityProviderMetadata,
spMetadata: {
metadata: spMetadata,
},
},
},
headers,
});
const sloRes = await auth.handler(
new Request(
"http://localhost:8081/api/auth/sso/saml2/sp/slo/slo-origin-bypass",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Origin: "http://external-idp.example.com",
Cookie: headers.get("cookie") || "",
},
body: new URLSearchParams({
SAMLRequest: Buffer.from("<LogoutRequest/>").toString("base64"),
}).toString(),
},
),
);
expect(sloRes.status).not.toBe(403);
});
});
describe("SP Metadata - SingleLogoutService", () => {
it("should include SingleLogoutService when SLO is enabled", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
sso({
saml: {
enableSingleLogout: true,
},
}),
],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "slo-metadata-test",
issuer: "http://localhost:8081",
domain: "slo-metadata.com",
samlConfig: {
entryPoint: "http://localhost:8081/sso",
cert: certificate,
idpMetadata: mockIdentityProviderMetadata,
spMetadata: {
entityID: "http://localhost:8081/sp",
},
},
},
headers,
});
const metadataRes = await auth.api.spMetadata({
query: { providerId: "slo-metadata-test" },
});
const metadataXml = await metadataRes.text();
expect(metadataXml).toContain("SingleLogoutService");
expect(metadataXml).toContain(
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
);
expect(metadataXml).toContain(
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
);
expect(metadataXml).toContain("/sso/saml2/sp/slo/slo-metadata-test");
});
it("should NOT include SingleLogoutService when SLO is disabled", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso()],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "slo-metadata-disabled",
issuer: "http://localhost:8081",
domain: "slo-metadata-disabled.com",
samlConfig: {
entryPoint: "http://localhost:8081/sso",
cert: certificate,
idpMetadata: mockIdentityProviderMetadata,
spMetadata: {
entityID: "http://localhost:8081/sp",
},
},
},
headers,
});
const metadataRes = await auth.api.spMetadata({
query: { providerId: "slo-metadata-disabled" },
});
const metadataXml = await metadataRes.text();
expect(metadataXml).not.toContain("SingleLogoutService");
});
});
describe("SP-initiated SLO (initiateSLO)", () => {
it("should return error when IdP has no SLO service configured", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
sso({
saml: {
enableSingleLogout: true,
},
}),
],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "sp-slo-no-idp-slo",
issuer: "http://localhost:8081",
domain: "sp-slo-no-idp.com",
samlConfig: {
entryPoint: "http://localhost:8081/sso",
cert: certificate,
idpMetadata: mockIdentityProviderMetadata,
spMetadata: {
metadata: spMetadata,
},
},
},
headers,
});
const initSloRes = await auth.handler(
new Request(
"http://localhost:8081/api/auth/sso/saml2/logout/sp-slo-no-idp-slo",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: headers.get("cookie") || "",
},
body: JSON.stringify({
callbackURL: "http://localhost:8081/logged-out",
}),
},
),
);
expect(initSloRes.status).toBe(400);
const body = await initSloRes.json();
expect(body.message).toContain(
"IdP does not support Single Logout Service",
);
});
it("should generate LogoutRequest and redirect to IdP", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
sso({
saml: {
enableSingleLogout: true,
},
}),
],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "sp-slo-initiate",
issuer: "http://localhost:8081",
domain: "sp-slo-initiate.com",
samlConfig: {
entryPoint: "http://localhost:8081/sso",
cert: certificate,
spMetadata: {
metadata: spMetadata,
},
idpMetadata: {
metadata: sloIdpMetadata,
},
},
},
headers,
});
const initSloRes = await auth.handler(
new Request(
"http://localhost:8081/api/auth/sso/saml2/logout/sp-slo-initiate",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: headers.get("cookie") || "",
},
body: JSON.stringify({
callbackURL: "http://localhost:8081/logged-out",
}),
},
),
);
expect(initSloRes.status).toBe(302);
const location = initSloRes.headers.get("location");
expect(location).toContain("http://localhost:8081/api/sso/saml2/idp/slo");
expect(location).toContain("SAMLRequest=");
// SP-initiated logout must revoke the local session, not just redirect.
const sessionAfter = await auth.api.getSession({ headers });
expect(sessionAfter).toBeNull();
});
});
describe("IdP-initiated SLO (LogoutRequest)", () => {
it("should process valid LogoutRequest and return LogoutResponse", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
sso({
saml: {
enableSingleLogout: true,
},
}),
],
});
const { headers } = await signInWithTestUser();
const sloServiceLocation =
"http://localhost:8081/api/auth/sso/saml2/sp/slo/idp-slo-test";
await auth.api.registerSSOProvider({
body: {
providerId: "idp-slo-test",
issuer: "http://localhost:8081/api/sso/saml2/idp/metadata",
domain: "idp-slo.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
spMetadata: {
metadata: spMetadata,
},
idpMetadata: {
metadata: sloIdpMetadata,
},
},
},
headers,
});
const idp = saml.IdentityProvider({
metadata: sloIdpMetadata,
privateKey: idPk,
privateKeyPass: "jXmKf9By6ruLnUdRo90G",
});
const sp = saml.ServiceProvider({
entityID: "http://localhost:8081",
assertionConsumerService: [
{
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
Location: "http://localhost:8081/callback",
},
],
singleLogoutService: [
{
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
Location: sloServiceLocation,
},
],
});
const logoutRequest = idp.createLogoutRequest(
sp,
saml.Constants.wording.binding.redirect,
{ nameID: "test@email.com" },
"http://localhost:8081/after-logout",
) as { context: string };
const url = new URL(logoutRequest.context);
const samlRequest = url.searchParams.get("SAMLRequest");
const relayState = url.searchParams.get("RelayState");
const sloRes = await auth.handler(
new Request(
`${sloServiceLocation}?SAMLRequest=${encodeURIComponent(samlRequest!)}&RelayState=${encodeURIComponent(relayState || "")}`,
{
method: "GET",
},
),
);
expect(sloRes.status).toBe(302);
const location = sloRes.headers.get("location");
expect(location).toContain("SAMLResponse=");
});
});
describe("LogoutResponse handling (SP-initiated flow completion)", () => {
it("should process LogoutResponse and redirect to relayState", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
sso({
saml: {
enableSingleLogout: true,
},
}),
],
});
const { headers } = await signInWithTestUser();
const sloServiceLocation =
"http://localhost:3000/api/auth/sso/saml2/sp/slo/logout-response-test";
const callbackUrl = "http://localhost:3000/logged-out";
await auth.api.registerSSOProvider({
body: {
providerId: "logout-response-test",
issuer: "http://localhost:8081/api/sso/saml2/idp/metadata",
domain: "logout-response-test.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
spMetadata: {
metadata: spMetadata,
},
idpMetadata: {
metadata: sloIdpMetadata,
},
},
},
headers,
});
const idp = saml.IdentityProvider({
metadata: sloIdpMetadata,
privateKey: idPk,
privateKeyPass: "jXmKf9By6ruLnUdRo90G",
});
const sp = saml.ServiceProvider({
entityID: "http://localhost:8081",
assertionConsumerService: [
{
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
Location: "http://localhost:8081/callback",
},
],
singleLogoutService: [
{
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
Location: sloServiceLocation,
},
],
});
const logoutResponse = idp.createLogoutResponse(
sp,
null as unknown as RequestInfo,
saml.Constants.wording.binding.redirect,
callbackUrl,
) as { context: string };
const url = new URL(logoutResponse.context);
const samlResponse = url.searchParams.get("SAMLResponse");
const relayState = url.searchParams.get("RelayState");
const sloRes = await auth.handler(
new Request(
`${sloServiceLocation}?SAMLResponse=${encodeURIComponent(samlResponse!)}&RelayState=${encodeURIComponent(relayState || "")}`,
{
method: "GET",
},
),
);
expect(sloRes.status).toBe(302);
const location = sloRes.headers.get("location");
expect(location).toBe(callbackUrl);
const setCookie = sloRes.headers.get("set-cookie");
expect(setCookie).toContain("better-auth.session_token=;");
});
it("should redirect to baseURL when relayState is missing", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
sso({
saml: {
enableSingleLogout: true,
},
}),
],
});
const { headers } = await signInWithTestUser();
const sloServiceLocation =
"http://localhost:3000/api/auth/sso/saml2/sp/slo/logout-no-relay";
await auth.api.registerSSOProvider({
body: {
providerId: "logout-no-relay",
issuer: "http://localhost:8081/api/sso/saml2/idp/metadata",
domain: "logout-no-relay.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
spMetadata: {
metadata: spMetadata,
},
idpMetadata: {
metadata: sloIdpMetadata,
},
},
},
headers,
});
const idp = saml.IdentityProvider({
metadata: sloIdpMetadata,
privateKey: idPk,
privateKeyPass: "jXmKf9By6ruLnUdRo90G",
});
const sp = saml.ServiceProvider({
entityID: "http://localhost:8081",
assertionConsumerService: [
{
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
Location: "http://localhost:8081/callback",
},
],
singleLogoutService: [
{
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
Location: sloServiceLocation,
},
],
});
const logoutResponse = idp.createLogoutResponse(
sp,
null as unknown as RequestInfo,
saml.Constants.wording.binding.redirect,
"",
) as { context: string };
const url = new URL(logoutResponse.context);
const samlResponse = url.searchParams.get("SAMLResponse");
const sloRes = await auth.handler(
new Request(
`${sloServiceLocation}?SAMLResponse=${encodeURIComponent(samlResponse!)}`,
{
method: "GET",
},
),
);
expect(sloRes.status).toBe(302);
const location = sloRes.headers.get("location");
expect(location).toBe("http://localhost:3000");
});
});
});
/**
* @see https://github.com/better-auth/better-auth/issues/8630
*/
describe("SAML provisionUser should only be called for new users", async () => {
const provisionUserFn = vi.fn();
const validateUserInfoFn = vi.fn();
const { auth, signInWithTestUser } = await getTestInstance({
user: {
validateUserInfo({ source }) {
if (source.method === "sso-saml") {
validateUserInfoFn(source);
}
},
},
plugins: [
sso({
provisionUser: provisionUserFn,
saml: { enableInResponseToValidation: false },
}),
],
});
it("should call provisionUser only on first sign-in (new user), not on subsequent sign-ins", async () => {
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "saml-provision-test",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
provisionUserFn.mockClear();
validateUserInfoFn.mockClear();
// First sign-in: new user -> provisionUser should be called
const response1 = await auth.api.signInSSO({
body: {
providerId: "saml-provision-test",
callbackURL: "http://localhost:3000/dashboard",
},
returnHeaders: true,
});
let samlResponse: any;
await betterFetch(response1.response?.url, {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
const samlRedirectUrl1 = new URL(response1.response?.url);
await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse.samlResponse,
RelayState: samlRedirectUrl1.searchParams.get("RelayState") ?? "",
},
headers: {
Cookie: response1.headers.get("set-cookie") ?? "",
},
params: {
providerId: "saml-provision-test",
},
asResponse: true,
});
expect(provisionUserFn).toHaveBeenCalledTimes(1);
expect(validateUserInfoFn).toHaveBeenCalledTimes(1);
expect(validateUserInfoFn.mock.calls[0]?.[0]).toMatchObject({
action: "create-user",
method: "sso-saml",
sso: {
providerId: "saml-provision-test",
},
});
expect(validateUserInfoFn.mock.calls[0]?.[0].sso?.profile).toBeDefined();
expect(validateUserInfoFn.mock.calls[0]?.[0].oauth).toBeUndefined();
provisionUserFn.mockClear();
// Second sign-in: existing user -> provisionUser should NOT be called
const response2 = await auth.api.signInSSO({
body: {
providerId: "saml-provision-test",
callbackURL: "http://localhost:3000/dashboard",
},
returnHeaders: true,
});
let samlResponse2: any;
await betterFetch(response2.response?.url, {
onSuccess: async (context) => {
samlResponse2 = await context.data;
},
});
const samlRedirectUrl2 = new URL(response2.response?.url);
await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse2.samlResponse,
RelayState: samlRedirectUrl2.searchParams.get("RelayState") ?? "",
},
headers: {
Cookie: response2.headers.get("set-cookie") ?? "",
},
params: {
providerId: "saml-provision-test",
},
asResponse: true,
});
expect(provisionUserFn).toHaveBeenCalledTimes(0);
expect(validateUserInfoFn).toHaveBeenCalledTimes(2);
expect(validateUserInfoFn.mock.calls[1]?.[0]).toMatchObject({
action: "sign-in",
method: "sso-saml",
sso: {
providerId: "saml-provision-test",
},
});
expect(validateUserInfoFn.mock.calls[1]?.[0].sso?.profile).toBeDefined();
expect(validateUserInfoFn.mock.calls[1]?.[0].oauth).toBeUndefined();
});
});
/**
* @see https://github.com/better-auth/better-auth/issues/8630
*/
describe("SAML provisionUserOnEveryLogin should call provisionUser on every sign-in", async () => {
const provisionUserFn = vi.fn();
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
sso({
provisionUser: provisionUserFn,
provisionUserOnEveryLogin: true,
saml: { enableInResponseToValidation: false },
}),
],
});
it("should call provisionUser on both first and subsequent sign-ins", async () => {
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "saml-provision-every-login",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
provisionUserFn.mockClear();
// First sign-in: new user -> provisionUser should be called
const response1 = await auth.api.signInSSO({
body: {
providerId: "saml-provision-every-login",
callbackURL: "http://localhost:3000/dashboard",
},
returnHeaders: true,
});
let samlResponse: any;
await betterFetch(response1.response?.url, {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
const samlRedirectUrl1 = new URL(response1.response?.url);
await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse.samlResponse,
RelayState: samlRedirectUrl1.searchParams.get("RelayState") ?? "",
},
headers: {
Cookie: response1.headers.get("set-cookie") ?? "",
},
params: {
providerId: "saml-provision-every-login",
},
asResponse: true,
});
expect(provisionUserFn).toHaveBeenCalledTimes(1);
provisionUserFn.mockClear();
// Second sign-in: existing user -> provisionUser should still be called
const response2 = await auth.api.signInSSO({
body: {
providerId: "saml-provision-every-login",
callbackURL: "http://localhost:3000/dashboard",
},
returnHeaders: true,
});
let samlResponse2: any;
await betterFetch(response2.response?.url, {
onSuccess: async (context) => {
samlResponse2 = await context.data;
},
});
const samlRedirectUrl2 = new URL(response2.response?.url);
await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse2.samlResponse,
RelayState: samlRedirectUrl2.searchParams.get("RelayState") ?? "",
},
headers: {
Cookie: response2.headers.get("set-cookie") ?? "",
},
params: {
providerId: "saml-provision-every-login",
},
asResponse: true,
});
expect(provisionUserFn).toHaveBeenCalledTimes(1);
});
});
/**
* End-to-end SP-initiated SAML SSO flow.
*
* Validates the complete lifecycle through the actual better-auth stack:
* provider registration, AuthnRequest generation, IdP response, SAML pipeline
* processing, user creation, session creation, and post-auth redirect.
*/
describe("SAML E2E: SP-initiated flow", () => {
it("should complete the full flow: register, sign-in, ACS, user + session created", async () => {
const { auth, signInWithTestUser, db } = await getTestInstance({
trustedProviders: ["e2e-saml"],
plugins: [
sso({
saml: { enableInResponseToValidation: false },
}),
],
});
const { headers } = await signInWithTestUser();
// 1. Register a SAML provider
await auth.api.registerSSOProvider({
body: {
providerId: "e2e-saml",
issuer: "https://sp.example.com/saml/e2e",
domain: "e2e.example.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: extractSigningCertificateFromMetadata(idpMetadata),
idpMetadata: {
entityID: "http://localhost:8081/api/sso/saml2/idp/metadata",
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
// 2. Initiate sign-in (generates AuthnRequest, returns IdP redirect URL)
const signInResponse = await auth.api.signInSSO({
body: {
providerId: "e2e-saml",
callbackURL: "/dashboard",
},
returnHeaders: true,
});
expect(signInResponse.response.url).toContain("http://localhost:8081");
expect(signInResponse.response.redirect).toBe(true);
// 3. Mock IdP generates a signed SAML response
let samlResponse: any;
await betterFetch(signInResponse.response.url, {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
expect(samlResponse?.samlResponse).toBeDefined();
// 4. POST the SAML response to the ACS endpoint (through the real pipeline)
const samlRedirectUrl = new URL(signInResponse.response.url);
const acsResponse = (await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse.samlResponse,
RelayState: samlRedirectUrl.searchParams.get("RelayState") ?? "",
},
headers: {
Cookie: signInResponse.headers.get("set-cookie") ?? "",
},
params: { providerId: "e2e-saml" },
asResponse: true,
})) as unknown as Response;
// 5. Verify redirect to the requested callbackURL (no error params)
expect(acsResponse.status).toBe(302);
const location = acsResponse.headers.get("location") || "";
expect(location).not.toContain("error=");
expect(location).toContain("/dashboard");
// 6. Verify an account was linked to the SSO provider
const accounts = await db.findMany({ model: "account" });
const ssoAccount = accounts.find((a: any) => a.providerId === "e2e-saml") as
| Record<string, any>
| undefined;
expect(ssoAccount).toBeDefined();
expect(ssoAccount!.accountId).toBe("test@email.com");
// 7. Verify the user exists and is linked
const users = await db.findMany({ model: "user" });
const ssoUser = users.find((u: any) => u.id === ssoAccount!.userId) as
| Record<string, any>
| undefined;
expect(ssoUser).toBeDefined();
expect(ssoUser!.email).toBe("test@email.com");
// 8. Verify a session was created for the SSO user
const sessions = await db.findMany({ model: "session" });
const ssoSession = sessions.find((s: any) => s.userId === ssoUser!.id) as
| Record<string, any>
| undefined;
expect(ssoSession).toBeDefined();
expect(ssoSession!.expiresAt).toBeDefined();
});
});
describe("SAML user resolution HTTP", () => {
type CookieJar = Map<string, string>;
const deferredCleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
for (const cleanup of deferredCleanups.splice(0).reverse()) {
await cleanup();
}
});
function storeResponseCookies(response: Response, cookies: CookieJar): void {
for (const setCookie of response.headers.getSetCookie()) {
const separatorIndex = setCookie.indexOf(";");
const cookie =
separatorIndex < 0 ? setCookie : setCookie.slice(0, separatorIndex);
const equalsIndex = cookie.indexOf("=");
if (equalsIndex < 1) continue;
const name = cookie.slice(0, equalsIndex);
const value = cookie.slice(equalsIndex + 1);
if (value) cookies.set(name, value);
else cookies.delete(name);
}
}
function cookieHeader(cookies: CookieJar): string {
return [...cookies].map(([name, value]) => `${name}=${value}`).join("; ");
}
async function fetchJSON<ResponseBody>(
url: string,
init: RequestInit,
cookies?: CookieJar,
): Promise<{ response: Response; body: ResponseBody }> {
const headers = new Headers(init.headers);
if (cookies?.size) headers.set("cookie", cookieHeader(cookies));
const response = await fetch(url, { ...init, headers });
if (cookies) storeResponseCookies(response, cookies);
return { response, body: (await response.json()) as ResponseBody };
}
function persistedSAMLConfiguration() {
return {
issuer: "https://service.example.com/saml",
entryPoint: "http://localhost:8081/api/sso/saml2/idp/redirect",
cert: extractSigningCertificateFromMetadata(idpMetadata),
idpMetadata: {
entityID: "http://localhost:8081/api/sso/saml2/idp/metadata",
},
spMetadata: {
metadata: spMetadata,
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
} satisfies NonNullable<
NonNullable<SSOOptions["defaultSSO"]>[number]["samlConfig"]
>;
}
async function createSAMLUserResolutionInstance(
options: SSOOptions,
authOptions: Record<string, unknown> = {},
samlConfiguration = persistedSAMLConfiguration(),
) {
const sqlite = new DatabaseSync(":memory:");
const database = new Kysely({
dialect: new NodeSqliteDialect({ database: sqlite }),
});
deferredCleanups.push(async () => {
await database.destroy();
});
const instance = await getHttpTestInstance(
{
...authOptions,
database: {
db: database,
type: "sqlite",
transaction: true,
},
plugins: [
sso({
...options,
saml: options.saml,
}),
],
trustedOrigins: ["http://localhost:8081"],
},
{ disableTestUser: true, testWith: "sqlite" },
);
deferredCleanups.push(() => instance.server.close());
const migrations = await getMigrations(instance.auth.options);
await migrations.runMigrations();
const context = await instance.auth.$context;
const providerOwner = await context.adapter.create<User>({
model: "user",
data: {
email: `provider-owner-${randomUUID()}@example.com`,
emailVerified: true,
name: "Provider Owner",
image: null,
createdAt: new Date(),
updatedAt: new Date(),
},
});
const providerRecord = await context.adapter.create<{ id: string }>({
model: "ssoProvider",
data: {
issuer: "https://service.example.com/saml",
domain: "example.com",
oidcConfig: null,
samlConfig: JSON.stringify(samlConfiguration),
userId: providerOwner.id,
providerId: "workforce-saml",
organizationId: null,
},
});
return Object.assign(instance, {
database,
providerOwner,
providerRecord,
sqlite,
});
}
async function replacePersistedSAMLConfiguration(
instance: Awaited<ReturnType<typeof createSAMLUserResolutionInstance>>,
samlConfiguration: ReturnType<typeof persistedSAMLConfiguration>,
) {
const context = await instance.auth.$context;
await context.adapter.update({
model: "ssoProvider",
where: [{ field: "id", value: instance.providerRecord.id }],
update: { samlConfig: JSON.stringify(samlConfiguration) },
});
}
async function initiateSAMLSignIn(baseURL: string) {
const cookies: CookieJar = new Map();
const signIn = await fetchJSON<{ url: string; redirect: boolean }>(
`${baseURL}/api/auth/sign-in/sso`,
{
method: "POST",
headers: { "content-type": "application/json", origin: baseURL },
body: JSON.stringify({
providerId: "workforce-saml",
callbackURL: `${baseURL}/employee`,
}),
},
cookies,
);
expect(signIn.response.status).toBe(200);
const identityProviderResponse = await fetchJSON<MockSAMLResponse>(
signIn.body.url,
{},
);
expect(identityProviderResponse.response.status).toBe(200);
return {
cookies,
identityProviderResponse: identityProviderResponse.body,
relayState: new URL(signIn.body.url).searchParams.get("RelayState") ?? "",
};
}
async function submitSAMLResponse(
baseURL: string,
signIn: Awaited<ReturnType<typeof initiateSAMLSignIn>>,
options: {
relayState?: string | null;
} = {},
) {
const body = new URLSearchParams({
SAMLResponse: signIn.identityProviderResponse.samlResponse,
});
if (options.relayState !== null) {
body.set("RelayState", options.relayState ?? signIn.relayState);
}
const callback = await fetch(
`${baseURL}/api/auth/sso/saml2/sp/acs/workforce-saml`,
{
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
cookie: cookieHeader(signIn.cookies),
},
body,
redirect: "manual",
},
);
storeResponseCookies(callback, signIn.cookies);
return callback;
}
async function completeSAMLSignIn(baseURL: string) {
const signIn = await initiateSAMLSignIn(baseURL);
const callback = await submitSAMLResponse(baseURL, signIn);
return {
callback,
cookies: signIn.cookies,
identityProviderResponse: signIn.identityProviderResponse,
};
}
function removeResponseSignature(xml: string): string {
return xml.replace(/<ds:Signature\b[\s\S]*?<\/ds:Signature>/, "");
}
function signSAMLContent(xml: string, assertion: boolean): string {
const signed = saml.SamlLib.constructSAMLSignature({
rawSamlMessage: xml,
referenceTagXPath: assertion
? "/*[local-name(.)='Response']/*[local-name(.)='Assertion']"
: undefined,
privateKey: idPk,
privateKeyPass: "jXmKf9By6ruLnUdRo90G",
signatureAlgorithm: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
signingCert: saml.Utility.normalizeCerString(
extractSigningCertificateFromMetadata(idpMetadata),
),
isBase64Output: false,
isMessageSigned: !assertion,
signatureConfig: {
prefix: "ds",
location: {
reference: assertion
? "/*[local-name(.)='Response']/*[local-name(.)='Assertion']/*[local-name(.)='Issuer']"
: "/*[local-name(.)='Response']/*[local-name(.)='Issuer']",
action: "after",
},
},
});
return Buffer.from(signed).toString("base64");
}
function withSAMLResponse(
signIn: Awaited<ReturnType<typeof initiateSAMLSignIn>>,
samlResponse: string,
) {
return {
...signIn,
identityProviderResponse: {
...signIn.identityProviderResponse,
samlResponse,
},
};
}
function strictAssertionSigningConfiguration(encrypted = false) {
const encryptionCertificate = certificate
.replace(/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----/g, "")
.replace(/\s+/g, "");
const serviceProviderMetadata = (
encrypted
? spMetadata.replace(
/<ds:X509Certificate>[^<]+<\/ds:X509Certificate>/g,
`<ds:X509Certificate>${encryptionCertificate}</ds:X509Certificate>`,
)
: spMetadata
).replace('WantAssertionsSigned="false"', 'WantAssertionsSigned="true"');
return {
...persistedSAMLConfiguration(),
idpMetadata: {
...persistedSAMLConfiguration().idpMetadata,
isAssertionEncrypted: encrypted,
},
spMetadata: {
...persistedSAMLConfiguration().spMetadata,
metadata: serviceProviderMetadata,
isAssertionEncrypted: encrypted,
encPrivateKey: encrypted ? idpPrivateKey : undefined,
encPrivateKeyPass: encrypted
? "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW"
: undefined,
},
wantAssertionsSigned: true,
};
}
function metadataRequiredAssertionSigningConfiguration() {
return {
...strictAssertionSigningConfiguration(),
wantAssertionsSigned: false,
};
}
function numericAssertionSigningConfiguration(value: "1" | "0") {
const configuration = persistedSAMLConfiguration();
return {
...configuration,
spMetadata: {
...configuration.spMetadata,
metadata: configuration.spMetadata.metadata.replace(
'WantAssertionsSigned="false"',
`WantAssertionsSigned="${value}"`,
),
},
wantAssertionsSigned: false,
};
}
async function encryptSAMLContent(xml: string): Promise<string> {
const encryptionCertificate = certificate
.replace(/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----/g, "")
.replace(/\s+/g, "");
const encryptedServiceProviderMetadata = spMetadata.replace(
/<ds:X509Certificate>[^<]+<\/ds:X509Certificate>/g,
`<ds:X509Certificate>${encryptionCertificate}</ds:X509Certificate>`,
);
const identityProvider = saml.IdentityProvider({
metadata: idpMetadata,
privateKey: idPk,
privateKeyPass: "jXmKf9By6ruLnUdRo90G",
isAssertionEncrypted: true,
});
const serviceProvider = saml.ServiceProvider({
metadata: encryptedServiceProviderMetadata,
});
return saml.SamlLib.encryptAssertion(
identityProvider,
serviceProvider,
xml,
);
}
async function expectNoAuthenticationWrites(
instance: Awaited<ReturnType<typeof createSAMLUserResolutionInstance>>,
) {
expect(await instance.db.count({ model: "account", where: [] })).toBe(0);
expect(await instance.db.count({ model: "session", where: [] })).toBe(0);
}
it("accepts a response-only signature when the effective SP policy is false", async () => {
const instance = await createSAMLUserResolutionInstance({});
const signIn = await initiateSAMLSignIn(instance.baseURL);
const xml = Buffer.from(
signIn.identityProviderResponse.samlResponse,
"base64",
).toString("utf8");
const assertionIndex = xml.indexOf("<saml:Assertion");
expect(xml.indexOf("<ds:Signature")).toBeLessThan(assertionIndex);
expect(xml.slice(assertionIndex)).not.toContain("<ds:Signature");
const callback = await submitSAMLResponse(instance.baseURL, signIn);
expect(callback.status).toBe(302);
expect(await instance.db.count({ model: "account", where: [] })).toBe(1);
expect(await instance.db.count({ model: "session", where: [] })).toBe(1);
});
it("rejects a response-only signature when the effective SP policy requires signed assertions", async () => {
const instance = await createSAMLUserResolutionInstance(
{},
{},
strictAssertionSigningConfiguration(),
);
const signIn = await initiateSAMLSignIn(instance.baseURL);
const callback = await submitSAMLResponse(instance.baseURL, signIn);
expect(callback.status).toBe(302);
expect(callback.headers.get("location")).toContain(
"error=invalid_saml_response",
);
await expectNoAuthenticationWrites(instance);
});
it("rejects a response-only signature when custom metadata requires signed assertions", async () => {
const instance = await createSAMLUserResolutionInstance(
{},
{},
metadataRequiredAssertionSigningConfiguration(),
);
const signIn = await initiateSAMLSignIn(instance.baseURL);
const callback = await submitSAMLResponse(instance.baseURL, signIn);
expect(callback.status).toBe(302);
expect(callback.headers.get("location")).toContain(
"error=invalid_saml_response",
);
await expectNoAuthenticationWrites(instance);
});
it("rejects a response-only signature when custom metadata uses XML Schema numeric true", async () => {
const instance = await createSAMLUserResolutionInstance(
{},
{},
numericAssertionSigningConfiguration("1"),
);
const signIn = await initiateSAMLSignIn(instance.baseURL);
const callback = await submitSAMLResponse(instance.baseURL, signIn);
expect(callback.status).toBe(302);
expect(callback.headers.get("location")).toContain(
"error=invalid_saml_response",
);
await expectNoAuthenticationWrites(instance);
});
it("accepts a response-only signature when custom metadata uses XML Schema numeric false", async () => {
const instance = await createSAMLUserResolutionInstance(
{},
{},
numericAssertionSigningConfiguration("0"),
);
const signIn = await initiateSAMLSignIn(instance.baseURL);
const callback = await submitSAMLResponse(instance.baseURL, signIn);
expect(callback.status).toBe(302);
expect(callback.headers.get("location")).toBe(
`${instance.baseURL}/employee`,
);
expect(await instance.db.count({ model: "account", where: [] })).toBe(1);
expect(await instance.db.count({ model: "session", where: [] })).toBe(1);
});
it.each([
[
"an invalid WantAssertionsSigned lexical value",
(metadata: string): string =>
metadata.replace(
'WantAssertionsSigned="false"',
'WantAssertionsSigned="TRUE"',
),
],
[
"an unqualified metadata tree",
(_metadata: string): string => `
<EntityDescriptor entityID="https://service.example.com/saml">
<SPSSODescriptor WantAssertionsSigned="false">
<AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://service.example.com/saml/acs" />
</SPSSODescriptor>
</EntityDescriptor>
`,
],
[
"a foreign root around a metadata namespace decoy",
(_metadata: string): string => `
<foreign:EntityDescriptor xmlns:foreign="urn:example:foreign" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://service.example.com/saml">
<md:EntityDescriptor entityID="https://service.example.com/saml">
<md:SPSSODescriptor WantAssertionsSigned="false">
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://service.example.com/saml/acs" />
</md:SPSSODescriptor>
</md:EntityDescriptor>
</foreign:EntityDescriptor>
`,
],
[
"a foreign service-provider descriptor",
(_metadata: string): string => `
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:foreign="urn:example:foreign" entityID="https://service.example.com/saml">
<foreign:SPSSODescriptor WantAssertionsSigned="false">
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://service.example.com/saml/acs" />
</foreign:SPSSODescriptor>
</md:EntityDescriptor>
`,
],
[
"a foreign POST endpoint namespace decoy",
(_metadata: string): string => `
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:foreign="urn:example:foreign" entityID="https://service.example.com/saml">
<md:SPSSODescriptor WantAssertionsSigned="false">
<foreign:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://service.example.com/saml/acs" />
</md:SPSSODescriptor>
</md:EntityDescriptor>
`,
],
[
"a valid descriptor followed by a foreign descriptor",
(_metadata: string): string => `
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:foreign="urn:example:foreign" entityID="https://service.example.com/saml">
<md:SPSSODescriptor WantAssertionsSigned="false">
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://service.example.com/saml/acs" />
</md:SPSSODescriptor>
<foreign:SPSSODescriptor WantAssertionsSigned="true">
<foreign:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://attacker.example.com/saml/acs" />
</foreign:SPSSODescriptor>
</md:EntityDescriptor>
`,
],
[
"a foreign POST endpoint before a valid endpoint",
(_metadata: string): string => `
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:foreign="urn:example:foreign" entityID="https://service.example.com/saml">
<md:SPSSODescriptor WantAssertionsSigned="false">
<foreign:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://attacker.example.com/saml/acs" />
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://service.example.com/saml/acs" />
</md:SPSSODescriptor>
</md:EntityDescriptor>
`,
],
[
"a foreign NameID format before a valid format",
(_metadata: string): string => `
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:foreign="urn:example:foreign" entityID="https://service.example.com/saml">
<md:SPSSODescriptor WantAssertionsSigned="false">
<foreign:NameIDFormat>urn:example:attacker</foreign:NameIDFormat>
<md:NameIDFormat>urn:example:valid</md:NameIDFormat>
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://service.example.com/saml/acs" />
</md:SPSSODescriptor>
</md:EntityDescriptor>
`,
],
] as const)(
"rejects an ACS request after persisted SP metadata changes to %s",
async (_description, replaceMetadata) => {
const instance = await createSAMLUserResolutionInstance({});
const signIn = await initiateSAMLSignIn(instance.baseURL);
const configuration = persistedSAMLConfiguration();
await replacePersistedSAMLConfiguration(instance, {
...configuration,
spMetadata: {
...configuration.spMetadata,
metadata: replaceMetadata(configuration.spMetadata.metadata),
},
});
const callback = await submitSAMLResponse(instance.baseURL, signIn);
expect(callback.status).toBe(302);
const location = new URL(callback.headers.get("location")!);
expect(location.origin).toBe(new URL(instance.baseURL).origin);
expect(location.pathname).toBe("/");
expect(location.searchParams.get("error")).toBe(
"saml_invalid_sp_metadata",
);
expect(location.searchParams.get("error_description")).toBe(
"Invalid SAML service provider metadata",
);
await expectNoAuthenticationWrites(instance);
},
);
it("accepts a cryptographically signed assertion through the HTTP ACS", async () => {
const instance = await createSAMLUserResolutionInstance(
{},
{},
strictAssertionSigningConfiguration(),
);
const signIn = await initiateSAMLSignIn(instance.baseURL);
const unsigned = removeResponseSignature(
Buffer.from(
signIn.identityProviderResponse.samlResponse,
"base64",
).toString("utf8"),
);
const callback = await submitSAMLResponse(
instance.baseURL,
withSAMLResponse(signIn, signSAMLContent(unsigned, true)),
);
expect(callback.status).toBe(302);
expect(callback.headers.get("location")).toBe(
`${instance.baseURL}/employee`,
);
expect(await instance.db.count({ model: "account", where: [] })).toBe(1);
expect(await instance.db.count({ model: "session", where: [] })).toBe(1);
});
it("accepts a signed assertion when custom metadata supplies the effective policy", async () => {
const instance = await createSAMLUserResolutionInstance(
{},
{},
metadataRequiredAssertionSigningConfiguration(),
);
const signIn = await initiateSAMLSignIn(instance.baseURL);
const unsigned = removeResponseSignature(
Buffer.from(
signIn.identityProviderResponse.samlResponse,
"base64",
).toString("utf8"),
);
const callback = await submitSAMLResponse(
instance.baseURL,
withSAMLResponse(signIn, signSAMLContent(unsigned, true)),
);
expect(callback.status).toBe(302);
expect(callback.headers.get("location")).toBe(
`${instance.baseURL}/employee`,
);
expect(await instance.db.count({ model: "account", where: [] })).toBe(1);
expect(await instance.db.count({ model: "session", where: [] })).toBe(1);
});
it("accepts valid response and assertion signatures through the strict HTTP ACS", async () => {
const instance = await createSAMLUserResolutionInstance(
{},
{},
strictAssertionSigningConfiguration(),
);
const signIn = await initiateSAMLSignIn(instance.baseURL);
const unsigned = removeResponseSignature(
Buffer.from(
signIn.identityProviderResponse.samlResponse,
"base64",
).toString("utf8"),
);
const assertionSigned = Buffer.from(
signSAMLContent(unsigned, true),
"base64",
).toString("utf8");
const responseAndAssertionSigned = signSAMLContent(assertionSigned, false);
const signedXml = Buffer.from(
responseAndAssertionSigned,
"base64",
).toString("utf8");
expect(signedXml.match(/<ds:Signature\b/g)).toHaveLength(2);
const callback = await submitSAMLResponse(
instance.baseURL,
withSAMLResponse(signIn, responseAndAssertionSigned),
);
expect(callback.status).toBe(302);
expect(callback.headers.get("location")).toBe(
`${instance.baseURL}/employee`,
);
expect(await instance.db.count({ model: "account", where: [] })).toBe(1);
expect(await instance.db.count({ model: "session", where: [] })).toBe(1);
});
it("rejects a valid response signature with a bogus assertion signature", async () => {
const instance = await createSAMLUserResolutionInstance(
{},
{},
strictAssertionSigningConfiguration(),
);
const signIn = await initiateSAMLSignIn(instance.baseURL);
const unsigned = removeResponseSignature(
Buffer.from(
signIn.identityProviderResponse.samlResponse,
"base64",
).toString("utf8"),
).replace(
/<saml:Assertion\b([^>]*)>/,
'<saml:Assertion$1><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" />',
);
const responseSigned = signSAMLContent(unsigned, false);
const callback = await submitSAMLResponse(
instance.baseURL,
withSAMLResponse(signIn, responseSigned),
);
expect(callback.status).toBe(302);
expect(callback.headers.get("location")).toContain(
"error=invalid_saml_response",
);
await expectNoAuthenticationWrites(instance);
});
it("rejects an encrypted unsigned assertion protected only by a response signature", async () => {
const instance = await createSAMLUserResolutionInstance(
{},
{},
strictAssertionSigningConfiguration(true),
);
const signIn = await initiateSAMLSignIn(instance.baseURL);
const unsigned = removeResponseSignature(
Buffer.from(
signIn.identityProviderResponse.samlResponse,
"base64",
).toString("utf8"),
);
const encrypted = await encryptSAMLContent(unsigned);
const encryptedXml = Buffer.from(encrypted, "base64").toString("utf8");
const responseSigned = signSAMLContent(encryptedXml, false);
const callback = await submitSAMLResponse(
instance.baseURL,
withSAMLResponse(signIn, responseSigned),
);
expect(callback.status).toBe(302);
expect(callback.headers.get("location")).toContain(
"error=invalid_saml_response",
);
await expectNoAuthenticationWrites(instance);
});
it("accepts a signed and encrypted assertion through the HTTP ACS", async () => {
const instance = await createSAMLUserResolutionInstance(
{},
{},
strictAssertionSigningConfiguration(true),
);
const signIn = await initiateSAMLSignIn(instance.baseURL);
const unsigned = removeResponseSignature(
Buffer.from(
signIn.identityProviderResponse.samlResponse,
"base64",
).toString("utf8"),
);
const assertionSigned = Buffer.from(
signSAMLContent(unsigned, true),
"base64",
).toString("utf8");
const encrypted = await encryptSAMLContent(assertionSigned);
const callback = await submitSAMLResponse(
instance.baseURL,
withSAMLResponse(signIn, encrypted),
);
expect(callback.status).toBe(302);
expect(callback.headers.get("location")).toBe(
`${instance.baseURL}/employee`,
);
expect(await instance.db.count({ model: "account", where: [] })).toBe(1);
expect(await instance.db.count({ model: "session", where: [] })).toBe(1);
});
it("links the signed NameID to the exact selected user without email fallback", async () => {
let selectedUserId = "";
const inputs: SSOUserResolutionInput[] = [];
const databases: DBTransactionAdapter[] = [];
const instance = await createSAMLUserResolutionInstance(
{
disableImplicitSignUp: true,
resolveUser(input, context) {
inputs.push(input);
databases.push(context.database);
return {
action: "link",
userId: selectedUserId,
profile: "preserve",
};
},
},
{ account: { storeAccountCookie: true } },
);
const context = await instance.auth.$context;
const selectedUser = await context.adapter.create<User>({
model: "user",
data: {
email: "directory-user@example.com",
emailVerified: false,
name: "Directory User",
image: "https://directory.example.com/avatar.png",
createdAt: new Date(),
updatedAt: new Date(),
},
});
selectedUserId = selectedUser.id;
const signIn = await completeSAMLSignIn(instance.baseURL);
expect(
Buffer.from(
signIn.identityProviderResponse.samlResponse,
"base64",
).toString("utf8"),
).toMatch(/InResponseTo="_[^"]+"/);
expect(signIn.callback.status).toBe(302);
expect(signIn.callback.headers.get("location")).toBe(
`${instance.baseURL}/employee`,
);
expect(signIn.callback.headers.getSetCookie().join(";")).toContain(
"account_data=",
);
expect(signIn.callback.headers.getSetCookie().join(";")).toContain(
"session_token=",
);
expect(inputs).toHaveLength(1);
expect(inputs[0]).toMatchObject({
protocol: "saml",
providerId: "workforce-saml",
accountKey: {
issuer: "http://localhost:8081/api/sso/saml2/idp/metadata",
accountId: "test@email.com",
},
providerUser: {
email: "test@email.com",
},
providerAttributes: {
firstName: "Test",
lastName: "User",
email: "test@email.com",
},
providerReference: {
providerId: "workforce-saml",
source: {
type: "persisted",
recordId: instance.providerRecord.id,
},
authenticationConfigurationFingerprint: expect.any(String),
},
});
expect(databases).toHaveLength(1);
const users = await instance.db.findMany<User>({
model: "user",
where: [],
});
expect(users).toHaveLength(2);
expect(users).not.toContainEqual(
expect.objectContaining({ email: "test@email.com" }),
);
expect(users).toContainEqual(
expect.objectContaining({
id: selectedUser.id,
email: "directory-user@example.com",
name: "Directory User",
}),
);
const accounts = await instance.db.findMany<Account>({
model: "account",
where: [],
});
expect(accounts).toEqual([
expect.objectContaining({
accountId: "test@email.com",
providerId: "workforce-saml",
userId: selectedUser.id,
}),
]);
expect(
await instance.db.count({
model: "session",
where: [{ field: "userId", value: selectedUser.id }],
}),
).toBe(1);
});
it.each(["reject", "throw", "account"] as const)(
"rolls back resolver and authentication writes on %s failure",
async (failure) => {
let markerUserId = "";
const instance = await createSAMLUserResolutionInstance(
{
resolveUser: async (_input, context) => {
await context.database.update({
model: "user",
where: [{ field: "id", value: markerUserId }],
update: { name: "must roll back" },
});
if (failure === "reject") {
return {
action: "reject",
code: "directory_access_denied",
message: "Directory access denied",
};
}
if (failure === "throw") {
throw new Error("resolver unavailable");
}
return { action: "continue" };
},
},
{
account: { storeAccountCookie: true },
databaseHooks:
failure === "account"
? {
account: {
create: {
before: () => false,
},
},
}
: undefined,
},
);
const context = await instance.auth.$context;
const marker = await context.adapter.create<User>({
model: "user",
data: {
email: `marker-${failure}@example.com`,
emailVerified: true,
name: "marker",
image: null,
createdAt: new Date(),
updatedAt: new Date(),
},
});
markerUserId = marker.id;
const signIn = await completeSAMLSignIn(instance.baseURL);
const redirect = new URL(
signIn.callback.headers.get("location")!,
instance.baseURL,
);
const expectedError = {
reject: "directory_access_denied",
throw: "SSO_USER_RESOLUTION_FAILED",
account: "unable_to_create_user",
}[failure];
expect(redirect.searchParams.get("error")).toBe(expectedError);
expect(signIn.callback.headers.getSetCookie().join(";")).not.toContain(
"account_data=",
);
expect(signIn.callback.headers.getSetCookie().join(";")).not.toContain(
"session_token=",
);
expect(await instance.db.count({ model: "account", where: [] })).toBe(0);
expect(await instance.db.count({ model: "session", where: [] })).toBe(0);
expect(
await instance.db.findOne<User>({
model: "user",
where: [{ field: "id", value: marker.id }],
}),
).toMatchObject({ name: "marker" });
expect(await instance.db.count({ model: "user", where: [] })).toBe(2);
},
);
it.each(["record", "configuration"] as const)(
"rejects a persisted provider %s change between sign-in and ACS",
async (change) => {
let resolverCalls = 0;
const instance = await createSAMLUserResolutionInstance({
resolveUser: () => {
resolverCalls += 1;
return { action: "continue" };
},
});
const signIn = await initiateSAMLSignIn(instance.baseURL);
if (change === "record") {
instance.sqlite
.prepare('UPDATE "ssoProvider" SET "id" = ? WHERE "providerId" = ?')
.run("replacement-provider-row", "workforce-saml");
} else {
const configuration = {
...persistedSAMLConfiguration(),
callbackUrl: `${instance.baseURL}/changed-callback`,
};
instance.sqlite
.prepare(
'UPDATE "ssoProvider" SET "samlConfig" = ? WHERE "providerId" = ?',
)
.run(JSON.stringify(configuration), "workforce-saml");
}
const callback = await submitSAMLResponse(instance.baseURL, signIn);
const redirect = new URL(
callback.headers.get("location")!,
instance.baseURL,
);
expect(redirect.searchParams.get("error")).toBe("invalid_state");
expect(resolverCalls).toBe(0);
expect(await instance.db.count({ model: "account", where: [] })).toBe(0);
expect(await instance.db.count({ model: "session", where: [] })).toBe(0);
},
);
it("preserves the signed SAML sign-in behavior when no resolver is configured", async () => {
const instance = await createSAMLUserResolutionInstance({});
const signIn = await completeSAMLSignIn(instance.baseURL);
expect(signIn.callback.status).toBe(302);
expect(signIn.callback.headers.get("location")).toBe(
`${instance.baseURL}/employee`,
);
const accounts = await instance.db.findMany<Account>({
model: "account",
where: [],
});
expect(accounts).toEqual([
expect.objectContaining({
accountId: "test@email.com",
providerId: "workforce-saml",
}),
]);
expect(
await instance.db.findOne<User>({
model: "user",
where: [{ field: "id", value: accounts[0]!.userId }],
}),
).toMatchObject({ email: "test@email.com" });
expect(await instance.db.count({ model: "session", where: [] })).toBe(1);
});
it.each(["missing", "corrupted", "legacy"] as const)(
"rejects an SP-initiated response with %s RelayState after the provider configuration changes",
async (relayStateCase) => {
let resolverCalls = 0;
const instance = await createSAMLUserResolutionInstance({
resolveUser: () => {
resolverCalls += 1;
return { action: "continue" };
},
});
const signIn = await initiateSAMLSignIn(instance.baseURL);
if (relayStateCase === "legacy") {
const context = await instance.auth.$context;
const verification =
await context.internalAdapter.findVerificationValue(
getAuthStateVerificationIdentifier(signIn.relayState),
);
expect(verification).not.toBeNull();
const storedState = JSON.parse(verification!.value) as Record<
string,
unknown
>;
const legacyState = Object.fromEntries(
Object.entries(storedState).filter(
([key]) => key !== "serverContext",
),
);
await context.adapter.update({
model: "verification",
where: [{ field: "id", value: verification!.id }],
update: { value: JSON.stringify(legacyState) },
});
}
const changedConfiguration = {
...persistedSAMLConfiguration(),
callbackUrl: `${instance.baseURL}/changed-callback`,
};
instance.sqlite
.prepare(
'UPDATE "ssoProvider" SET "samlConfig" = ? WHERE "providerId" = ?',
)
.run(JSON.stringify(changedConfiguration), "workforce-saml");
const callback = await submitSAMLResponse(instance.baseURL, signIn, {
relayState:
relayStateCase === "missing"
? null
: relayStateCase === "corrupted"
? "corrupted-relay-state"
: signIn.relayState,
});
const redirect = new URL(
callback.headers.get("location")!,
instance.baseURL,
);
expect(redirect.searchParams.get("error")).toBe("invalid_state");
expect(resolverCalls).toBe(0);
expect(callback.headers.getSetCookie().join(";")).not.toContain(
"account_data=",
);
expect(callback.headers.getSetCookie().join(";")).not.toContain(
"session_token=",
);
expect(
[...signIn.cookies.keys()].some(
(name) =>
name.includes("account_data") || name.includes("session_token"),
),
).toBe(false);
expect(await instance.db.count({ model: "account", where: [] })).toBe(0);
expect(await instance.db.count({ model: "session", where: [] })).toBe(0);
},
);
it("accepts a true explicitly allowed IdP-initiated response without RelayState or InResponseTo", async () => {
let resolverCalls = 0;
const instance = await createSAMLUserResolutionInstance({
resolveUser: () => {
resolverCalls += 1;
return { action: "continue" };
},
saml: {
allowIdpInitiated: true,
idpInitiatedCallbackUrl: "/employee",
},
});
const identityProviderResponse = await fetchJSON<MockSAMLResponse>(
"http://localhost:8081/api/sso/saml2/idp/post?idpInitiated=true",
{},
);
expect(identityProviderResponse.response.status).toBe(200);
const cookies: CookieJar = new Map();
const callback = await fetch(
`${instance.baseURL}/api/auth/sso/saml2/sp/acs/workforce-saml`,
{
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: identityProviderResponse.body.samlResponse,
}),
redirect: "manual",
},
);
storeResponseCookies(callback, cookies);
expect(callback.status).toBe(302);
expect(callback.headers.get("location")).toBe("/employee");
expect(resolverCalls).toBe(1);
expect(callback.headers.getSetCookie().join(";")).toContain(
"session_token=",
);
expect(await instance.db.count({ model: "account", where: [] })).toBe(1);
expect(await instance.db.count({ model: "session", where: [] })).toBe(1);
});
it("rejects an explicitly allowed IdP-initiated response with malformed RelayState", async () => {
let resolverCalls = 0;
const instance = await createSAMLUserResolutionInstance({
resolveUser: () => {
resolverCalls += 1;
return { action: "continue" };
},
saml: {
allowIdpInitiated: true,
idpInitiatedCallbackUrl: "/employee",
},
});
const identityProviderResponse = await fetchJSON<MockSAMLResponse>(
"http://localhost:8081/api/sso/saml2/idp/post?idpInitiated=true",
{},
);
expect(identityProviderResponse.response.status).toBe(200);
const cookies: CookieJar = new Map();
const callback = await fetch(
`${instance.baseURL}/api/auth/sso/saml2/sp/acs/workforce-saml`,
{
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: identityProviderResponse.body.samlResponse,
RelayState: "malformed-relay-state",
}),
redirect: "manual",
},
);
storeResponseCookies(callback, cookies);
expect(callback.status).toBe(302);
const redirect = new URL(
callback.headers.get("location")!,
instance.baseURL,
);
expect(redirect.searchParams.get("error")).toBe("invalid_state");
expect(resolverCalls).toBe(0);
expect(callback.headers.getSetCookie().join(";")).not.toContain(
"account_data=",
);
expect(callback.headers.getSetCookie().join(";")).not.toContain(
"session_token=",
);
expect(
[...cookies.keys()].some(
(name) =>
name.includes("account_data") || name.includes("session_token"),
),
).toBe(false);
expect(await instance.db.count({ model: "account", where: [] })).toBe(0);
expect(await instance.db.count({ model: "session", where: [] })).toBe(0);
});
});
describe("PEM normalization", () => {
it("should complete full SAML flow with whitespace-indented keys", async () => {
// Indent every line of every PEM to simulate keys from indented configs
const indent = (pem: string) =>
pem
.split("\n")
.map((line) => ` ${line}`)
.join("\n");
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso()],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "indent-test",
issuer: "http://localhost:8081",
domain: "indent.example.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: indent(certificate),
idpMetadata: {
metadata: idpMetadata,
privateKey: indent(idpPrivateKey),
privateKeyPass: "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW",
},
spMetadata: {
metadata: spMetadata,
privateKey: indent(spPrivateKey),
privateKeyPass: "VHOSp5RUiBcrsjrcAuXFwU1NKCkGA8px",
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
// Step 1: initiate sign-in (constructs SP/IdP from indented keys)
const signInResponse = await auth.api.signInSSO({
body: {
providerId: "indent-test",
callbackURL: "/dashboard",
},
});
expect(signInResponse.url).toContain("http://localhost:8081");
// Step 2: get SAML response from mock IdP
let samlResponse: any;
await betterFetch(signInResponse.url as string, {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
expect(samlResponse?.samlResponse).toBeDefined();
// Step 3: post to ACS and verify session is created
const acsResponse = (await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse.samlResponse,
},
params: { providerId: "indent-test" },
asResponse: true,
})) as unknown as Response;
expect(acsResponse.status).toBe(302);
});
});
/**
* @see https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf
* @see https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf
*/
describe("SAML SSO Hardening", () => {
/**
* SAML 2.0 Core §2.3.3: ACS URL in SP metadata MUST match the ACS URL
* in AuthnRequests. Both derive from baseURL + providerId.
*/
describe("ACS URL consistency (provider.id vs providerId)", () => {
it("should use providerId (not internal row ID) in SP metadata ACS URL", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso()],
});
const { headers } = await signInWithTestUser();
// When spMetadata.metadata is absent, the SP metadata endpoint should
// generate an ACS URL from baseURL + providerId, not from provider.id (row UUID).
await auth.api.registerSSOProvider({
body: {
providerId: "acs-consistency-test",
issuer: "http://localhost:8081",
domain: "acs-test.example.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
idpMetadata: mockIdentityProviderMetadata,
spMetadata: {},
},
},
headers,
});
const spMetadataRes = await auth.api.spMetadata({
query: { providerId: "acs-consistency-test" },
});
const xml = await spMetadataRes.text();
// The generated metadata should contain the providerId, not the row UUID.
expect(xml).toContain("acs-consistency-test");
});
it("should produce matching ACS URLs in SP metadata and AuthnRequest", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso()],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "acs-match-test",
issuer: "http://localhost:8081",
domain: "acs-match.example.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
idpMetadata: {
metadata: idpMetadata,
},
spMetadata: {},
},
},
headers,
});
// Get ACS URL from SP metadata
const spMetadataRes = await auth.api.spMetadata({
query: { providerId: "acs-match-test" },
});
const metadataXml = await spMetadataRes.text();
const metadataAcsMatch = metadataXml.match(
/Location="([^"]*\/sso\/saml2\/sp\/acs[^"]*)"/,
);
expect(metadataAcsMatch).not.toBeNull();
const metadataAcsUrl = metadataAcsMatch![1];
// Get ACS URL from AuthnRequest
const signInResponse = await auth.api.signInSSO({
body: {
providerId: "acs-match-test",
callbackURL: "/dashboard",
},
});
// The redirect URL's SAMLRequest contains the ACS URL; we verify
// they both derive from the same providerId
expect(metadataAcsUrl).toContain("acs-match-test");
expect(signInResponse.url).toBeDefined();
});
});
/**
* Better-auth specific: acsEndpoint provider lookup MUST fall back to the
* database when defaultSSO is configured but no entry matches the providerId.
*/
describe("Provider lookup fallback", () => {
it("should find DB provider even when defaultSSO is configured with different providers", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
sso({
defaultSSO: [
{
providerId: "default-provider",
domain: "default.example.com",
samlConfig: {
issuer: "http://localhost:8081",
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
idpMetadata: {
entityID:
"http://localhost:8081/api/sso/saml2/idp/metadata",
},
spMetadata: { metadata: spMetadata },
},
},
],
}),
],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "db-provider",
issuer: "http://localhost:8081",
domain: "db.example.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
spMetadata: { metadata: spMetadata },
idpMetadata: { metadata: idpMetadata },
},
},
headers,
});
// POST a SAMLResponse to ACS for the DB provider
// The endpoint MUST find "db-provider" in the database, not fall back to defaultSSO[0]
const response = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/db-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: "invalid-but-tests-provider-lookup",
}),
},
),
);
// Should NOT return 404 (which would mean it didn't find the provider)
// It will return 400 because the SAMLResponse is invalid, but the
// provider lookup itself should succeed
expect(response.status).not.toBe(404);
});
it("should return 404 for nonexistent providerId even when defaultSSO is configured", async () => {
const { auth } = await getTestInstance({
plugins: [
sso({
defaultSSO: [
{
providerId: "default-provider",
domain: "default.example.com",
samlConfig: {
issuer: "http://localhost:8081",
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
idpMetadata: {
entityID:
"http://localhost:8081/api/sso/saml2/idp/metadata",
},
spMetadata: { metadata: spMetadata },
},
},
],
}),
],
});
// POST to ACS for a provider that doesn't exist anywhere
const response = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/nonexistent-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: "irrelevant",
}),
},
),
);
// MUST be 404 — should NOT fall back to defaultSSO[0]
expect(response.status).toBe(404);
});
});
/**
* Better-auth specific: registration-time validation should catch config
* errors early rather than failing silently at sign-in time.
*/
describe("Registration-time config validation", () => {
it("should reject SAML mappings that attempt to replace the NameID identity", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso()],
});
const { headers } = await signInWithTestUser();
const requestHeaders = new Headers(headers);
requestHeaders.set("Content-Type", "application/json");
const response = await auth.handler(
new Request("http://localhost:3000/api/auth/sso/register", {
method: "POST",
headers: requestHeaders,
body: JSON.stringify({
providerId: "mapped-saml-identity",
issuer: "https://sp.example.com/saml",
domain: "example.com",
samlConfig: {
entryPoint: "https://idp.example.com/sso",
cert: "placeholder",
idpMetadata: { entityID: "https://idp.example.com" },
mapping: {
id: "employee_id",
email: "email",
name: "displayName",
},
},
}),
}),
);
expect(response.status).toBe(400);
});
it("should reject manual IdP configuration without an explicit entity ID", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso()],
});
const { headers } = await signInWithTestUser();
const requestHeaders = new Headers(headers);
requestHeaders.set("Content-Type", "application/json");
const response = await auth.handler(
new Request("http://localhost:3000/api/auth/sso/register", {
method: "POST",
headers: requestHeaders,
body: JSON.stringify({
providerId: "ambiguous-idp-authority",
issuer: "https://sp.example.com/saml",
domain: "example.com",
samlConfig: {
entryPoint: "https://idp.example.com/sso",
cert: "placeholder",
spMetadata: {},
},
}),
}),
);
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({
message: expect.stringContaining("idpMetadata.entityID"),
});
});
it("should reject registration with empty entryPoint and no idpMetadata.metadata", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso()],
});
const { headers } = await signInWithTestUser();
// This config has no way to reach the IdP: entryPoint is empty and
// there's no idpMetadata.metadata to extract it from. The registration
// should fail instead of creating a broken provider that silently fails
// at sign-in time.
await expect(
auth.api.registerSSOProvider({
body: {
providerId: "broken-config",
issuer: "http://localhost:8081",
domain: "broken.example.com",
samlConfig: {
entryPoint: "not-a-url",
cert: "placeholder",
spMetadata: {},
},
},
headers,
}),
).rejects.toMatchObject({
body: expect.objectContaining({
message: expect.stringContaining("entryPoint"),
}),
});
});
});
/**
* SAML 2.0 Bindings §3.5.3 - RelayState is an opaque reference to state
* maintained at the SP. The SP MUST use it to determine where to redirect
* the user after authentication.
*/
describe("RelayState controls post-auth redirect", () => {
it("should redirect to RelayState callbackURL after authentication", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [sso({ saml: { enableInResponseToValidation: false } })],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "relay-priority-test",
issuer: "http://localhost:8081",
domain: "relay.example.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
idpMetadata: { metadata: idpMetadata },
spMetadata: { metadata: spMetadata },
},
},
headers,
});
// Sign in with a specific callbackURL
const signInResponse = await auth.api.signInSSO({
body: {
providerId: "relay-priority-test",
callbackURL: "/from-relay-state",
},
});
// Get SAMLResponse from mock IdP
let samlResponse: any;
await betterFetch(signInResponse.url as string, {
onSuccess: async (context) => {
samlResponse = await context.data;
},
});
// Extract RelayState from the sign-in redirect URL
const signInUrl = new URL(signInResponse.url as string);
const relayState = signInUrl.searchParams.get("RelayState") ?? "";
// POST to callback with RelayState
const callbackResponse = (await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse.samlResponse,
RelayState: relayState,
},
params: { providerId: "relay-priority-test" },
asResponse: true,
})) as unknown as Response;
const location = callbackResponse.headers.get("location") || "";
// MUST redirect to the RelayState callbackURL
expect(location).toContain("/from-relay-state");
expect(location).not.toContain("/from-config");
});
});
/**
* @see https://github.com/better-auth/better-auth/issues/10329
*/
describe("IdP-initiated SAML login with split origin redirect", () => {
const frontendOrigin = "https://frontend.example.com";
/**
* @see https://github.com/better-auth/better-auth/issues/10329
*/
it("should allow a trusted cross-origin redirect with the ACS pathname", () => {
const appOrigin = "http://localhost:3000";
const callbackPath = `${appOrigin}/api/auth/sso/saml2/sp/acs/shared-path-provider`;
const frontendCallback = `${frontendOrigin}/api/auth/sso/saml2/sp/acs/shared-path-provider`;
expect(
getSafeRedirectUrl(
[frontendCallback],
callbackPath,
appOrigin,
() => true,
),
).toBe(frontendCallback);
});
/**
* @see https://github.com/better-auth/better-auth/issues/10329
*/
it.each([
"/\\evil.example.com",
"/%2fevil.example.com",
"/%5cevil.example.com",
])("should skip unsafe relative redirect candidate %s", (candidate) => {
const appOrigin = "http://localhost:3000";
const callbackPath = `${appOrigin}/api/auth/sso/saml2/sp/acs/provider`;
expect(
getSafeRedirectUrl(
[candidate, "/dashboard"],
callbackPath,
appOrigin,
() => true,
),
).toBe("/dashboard");
});
async function getIdPInitiatedSAMLResponse(): Promise<string> {
let response: MockSAMLResponse | undefined;
await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
onSuccess: async (context) => {
response = (await context.data) as MockSAMLResponse;
},
});
if (!response) throw new Error("Mock IdP did not return a SAML response");
return response.samlResponse;
}
/**
* @see https://github.com/better-auth/better-auth/issues/10329
*/
it("should redirect to provider-level idpInitiatedCallbackUrl when RelayState is missing (IdP-initiated)", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
trustedOrigins: [frontendOrigin],
plugins: [
sso({
saml: {
allowIdpInitiated: true,
enableInResponseToValidation: false,
},
}),
],
});
const { headers } = await signInWithTestUser();
// Provider with provider-level idpInitiatedCallbackUrl
await auth.api.registerSSOProvider({
body: {
providerId: "split-origin-provider",
issuer: "http://localhost:8081",
domain: "split.example.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
callbackUrl:
"http://localhost:3000/api/auth/sso/saml2/callback/split-origin-provider",
idpInitiatedCallbackUrl: `${frontendOrigin}/provider-idp-redirect`,
idpMetadata: { metadata: idpMetadata },
spMetadata: { metadata: spMetadata },
},
},
headers,
});
const samlResponse = await getIdPInitiatedSAMLResponse();
const callbackResponse = (await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse,
},
params: { providerId: "split-origin-provider" },
asResponse: true,
})) as unknown as Response;
expect(callbackResponse.status).toBe(302);
expect(callbackResponse.headers.get("location")).toBe(
`${frontendOrigin}/provider-idp-redirect`,
);
});
/**
* @see https://github.com/better-auth/better-auth/issues/10329
*/
it("should redirect to global idpInitiatedCallbackUrl when RelayState is missing (IdP-initiated fallback)", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
trustedOrigins: [frontendOrigin],
plugins: [
sso({
saml: {
allowIdpInitiated: true,
enableInResponseToValidation: false,
idpInitiatedCallbackUrl: `${frontendOrigin}/global-idp-redirect`,
},
}),
],
});
const { headers } = await signInWithTestUser();
// Provider without provider-level idpInitiatedCallbackUrl (should fall back to global plugin option)
await auth.api.registerSSOProvider({
body: {
providerId: "global-fallback-provider",
issuer: "http://localhost:8081",
domain: "fallback.example.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
callbackUrl:
"http://localhost:3000/api/auth/sso/saml2/callback/global-fallback-provider",
idpMetadata: { metadata: idpMetadata },
spMetadata: { metadata: spMetadata },
},
},
headers,
});
const samlResponse = await getIdPInitiatedSAMLResponse();
const fallbackResponse = (await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse,
},
params: { providerId: "global-fallback-provider" },
asResponse: true,
})) as unknown as Response;
expect(fallbackResponse.status).toBe(302);
expect(fallbackResponse.headers.get("location")).toBe(
`${frontendOrigin}/global-idp-redirect`,
);
});
/**
* @see https://github.com/better-auth/better-auth/issues/10329
*/
it("should prioritize SP-initiated RelayState over idpInitiatedCallbackUrl", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
trustedOrigins: [frontendOrigin],
plugins: [
sso({
saml: {
allowIdpInitiated: true,
enableInResponseToValidation: false,
idpInitiatedCallbackUrl: `${frontendOrigin}/global-idp-redirect`,
},
}),
],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "priority-test-provider",
issuer: "http://localhost:8081",
domain: "priority.example.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
callbackUrl:
"http://localhost:3000/api/auth/sso/saml2/callback/priority-test-provider",
idpInitiatedCallbackUrl: `${frontendOrigin}/provider-idp-redirect`,
idpMetadata: { metadata: idpMetadata },
spMetadata: { metadata: spMetadata },
},
},
headers,
});
// Sign in with SP-initiated flow requesting a specific callbackURL
const signInResponse = await auth.api.signInSSO({
body: {
providerId: "priority-test-provider",
callbackURL: "/from-relay-state",
},
});
let samlResponse: MockSAMLResponse | undefined;
await betterFetch(signInResponse.url as string, {
onSuccess: async (context) => {
samlResponse = (await context.data) as MockSAMLResponse;
},
});
if (!samlResponse)
throw new Error("Mock IdP did not return a SAML response");
const signInUrl = new URL(signInResponse.url as string);
const relayState = signInUrl.searchParams.get("RelayState") ?? "";
const callbackResponse = (await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse.samlResponse,
RelayState: relayState,
},
params: { providerId: "priority-test-provider" },
asResponse: true,
})) as unknown as Response;
expect(callbackResponse.status).toBe(302);
const location = callbackResponse.headers.get("location") || "";
expect(location).toContain("/from-relay-state");
expect(location).not.toContain("/provider-idp-redirect");
expect(location).not.toContain("/global-idp-redirect");
});
/**
* @see https://github.com/better-auth/better-auth/issues/10329
*/
it("should preserve the signed RelayState error callback on validation errors", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
trustedOrigins: [frontendOrigin],
plugins: [
sso({
saml: {
allowIdpInitiated: true,
idpInitiatedCallbackUrl: `${frontendOrigin}/global-idp-redirect`,
},
}),
],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "error-relay-state-provider",
issuer: "http://localhost:8081",
domain: "error-relay-state.example.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
callbackUrl:
"http://localhost:3000/api/auth/sso/saml2/callback/error-relay-state-provider",
idpInitiatedCallbackUrl: `${frontendOrigin}/provider-idp-redirect`,
idpMetadata: { metadata: idpMetadata },
spMetadata: { metadata: spMetadata },
},
},
headers,
});
const signInResponse = await auth.api.signInSSO({
body: {
providerId: "error-relay-state-provider",
callbackURL: `${frontendOrigin}/success`,
errorCallbackURL: `${frontendOrigin}/saml-error#details`,
},
});
const signInUrl = new URL(signInResponse.url as string);
const relayState = signInUrl.searchParams.get("RelayState") ?? "";
const callbackResponse = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/error-relay-state-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: "invalid-saml-response-garbage",
RelayState: relayState,
}),
},
),
);
expect(callbackResponse.status).toBe(302);
const redirectUrl = new URL(
callbackResponse.headers.get("location") || "",
);
expect(redirectUrl.origin).toBe(frontendOrigin);
expect(redirectUrl.pathname).toBe("/saml-error");
expect(redirectUrl.searchParams.get("error")).toBe(
"saml_invalid_encoding",
);
expect(redirectUrl.hash).toBe("#details");
});
/**
* @see https://github.com/better-auth/better-auth/issues/10329
*/
it("should preserve the signed error callback on binding validation errors", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
trustedOrigins: [frontendOrigin],
plugins: [
sso({
saml: {
allowIdpInitiated: true,
idpInitiatedCallbackUrl: `${frontendOrigin}/global-idp-redirect`,
},
}),
],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "binding-error-relay-state-provider",
issuer: "http://localhost:8081",
domain: "binding-error.example.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
callbackUrl:
"http://localhost:3000/api/auth/sso/saml2/sp/acs/binding-error-relay-state-provider",
idpInitiatedCallbackUrl: `${frontendOrigin}/provider-idp-redirect`,
idpMetadata: { metadata: idpMetadata },
spMetadata: { metadata: spMetadata },
},
},
headers,
});
const signInResponse = await auth.api.signInSSO({
body: {
providerId: "binding-error-relay-state-provider",
callbackURL: `${frontendOrigin}/success`,
errorCallbackURL: `${frontendOrigin}/binding-error#details`,
},
});
const idpResponseUrl = new URL(signInResponse.url as string);
idpResponseUrl.searchParams.set(
"audience",
"https://unexpected.example.com/saml/metadata",
);
let samlResponse: MockSAMLResponse | undefined;
await betterFetch(idpResponseUrl.toString(), {
onSuccess: async (context) => {
samlResponse = (await context.data) as MockSAMLResponse;
},
});
if (!samlResponse)
throw new Error("Mock IdP did not return a SAML response");
const relayState = idpResponseUrl.searchParams.get("RelayState") ?? "";
const callbackResponse = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/binding-error-relay-state-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: samlResponse.samlResponse,
RelayState: relayState,
}),
},
),
);
expect(callbackResponse.status).toBe(302);
const redirectUrl = new URL(
callbackResponse.headers.get("location") || "",
);
expect(redirectUrl.origin).toBe(frontendOrigin);
expect(redirectUrl.pathname).toBe("/binding-error");
expect(redirectUrl.searchParams.get("error")).toBe(
"invalid_saml_response",
);
expect(redirectUrl.hash).toBe("#details");
});
/**
* @see https://github.com/better-auth/better-auth/issues/10329
*/
it("should prevent open redirect attacks on idpInitiatedCallbackUrl", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
sso({
saml: {
allowIdpInitiated: true,
enableInResponseToValidation: false,
idpInitiatedCallbackUrl: "http://attacker.com/malicious",
},
}),
],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "unsafe-provider",
issuer: "http://localhost:8081",
domain: "unsafe.example.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
idpMetadata: { metadata: idpMetadata },
spMetadata: { metadata: spMetadata },
},
},
headers,
});
const samlResponse = await getIdPInitiatedSAMLResponse();
const callbackResponse = (await auth.api.acsEndpoint({
method: "POST",
body: {
SAMLResponse: samlResponse,
},
params: { providerId: "unsafe-provider" },
asResponse: true,
})) as unknown as Response;
expect(callbackResponse.status).toBe(302);
const location = callbackResponse.headers.get("location") || "";
// Should fall back to appOrigin because http://attacker.com/malicious is not a trusted origin
expect(location).not.toContain("attacker.com");
expect(location).toBe("http://localhost:3000"); // appOrigin
});
/**
* @see https://github.com/better-auth/better-auth/issues/10329
*/
it("should redirect to idpInitiatedCallbackUrl on SAML validation error", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
trustedOrigins: [frontendOrigin],
plugins: [
sso({
saml: {
allowIdpInitiated: true,
idpInitiatedCallbackUrl: `${frontendOrigin}/global-idp-redirect`,
},
}),
],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "error-provider",
issuer: "http://localhost:8081",
domain: "error.example.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
callbackUrl:
"http://localhost:3000/api/auth/sso/saml2/callback/error-provider",
idpInitiatedCallbackUrl: `${frontendOrigin}/provider-idp-redirect#saml`,
idpMetadata: { metadata: idpMetadata },
spMetadata: { metadata: spMetadata },
},
},
headers,
});
const callbackResponse = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/error-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: "invalid-saml-response-garbage",
RelayState:
"http://localhost:3000/api/auth/sso/saml2/sp/acs/error-provider",
}),
},
),
);
expect(callbackResponse.status).toBe(302);
const location = callbackResponse.headers.get("location") || "";
const redirectUrl = new URL(location);
expect(redirectUrl.origin).toBe(frontendOrigin);
expect(redirectUrl.pathname).toBe("/provider-idp-redirect");
expect(redirectUrl.searchParams.get("error")).toBe(
"saml_invalid_encoding",
);
expect(redirectUrl.hash).toBe("#saml");
});
/**
* @see https://github.com/better-auth/better-auth/issues/10329
*/
it("should use the global fallback when provider lookup fails before error redirect resolution", async () => {
const data = {
user: [],
session: [],
verification: [],
account: [],
ssoProvider: [],
};
const memory = memoryAdapter(data);
let failProviderLookup = false;
const database: typeof memory = (options) => {
const adapter = memory(options);
const findOne: typeof adapter.findOne = async (query) => {
if (failProviderLookup && query.model === "ssoProvider") {
throw new Error("provider lookup unavailable");
}
return adapter.findOne(query);
};
return { ...adapter, findOne };
};
const { auth, signInWithTestUser } = await getTestInstance({
database,
trustedOrigins: [frontendOrigin],
plugins: [
sso({
saml: {
allowIdpInitiated: true,
idpInitiatedCallbackUrl: `${frontendOrigin}/global-idp-redirect`,
maxResponseSize: 16,
},
}),
],
});
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "error-lookup-provider",
issuer: "http://localhost:8081",
domain: "error-lookup.example.com",
samlConfig: {
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
cert: certificate,
callbackUrl:
"http://localhost:3000/api/auth/sso/saml2/callback/error-lookup-provider",
idpInitiatedCallbackUrl: `${frontendOrigin}/provider-idp-redirect`,
idpMetadata: { metadata: idpMetadata },
spMetadata: { metadata: spMetadata },
},
},
headers,
});
failProviderLookup = true;
const callbackResponse = await auth.handler(
new Request(
"http://localhost:3000/api/auth/sso/saml2/sp/acs/error-lookup-provider",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
SAMLResponse: "response-exceeds-size-limit",
}),
},
),
);
expect(callbackResponse.status).toBe(302);
const redirectUrl = new URL(
callbackResponse.headers.get("location") || "",
);
expect(redirectUrl.origin).toBe(frontendOrigin);
expect(redirectUrl.pathname).toBe("/global-idp-redirect");
expect(redirectUrl.searchParams.get("error")).toBe("saml_error");
});
});
});