mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-18 11:32:15 -05:00
8767 lines
269 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|