@better-auth/sso 1.4.18 → 1.4.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/saml.test.ts CHANGED
@@ -574,6 +574,244 @@ describe("SAML SSO with defaultSSO array", async () => {
574
574
  });
575
575
  });
576
576
 
577
+ describe("SAML SSO with signed AuthnRequests", async () => {
578
+ // IdP metadata with WantAuthnRequestsSigned="true" for testing signed requests
579
+ const idpMetadataWithSignedRequests = `
580
+ <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://localhost:8081/api/sso/saml2/idp/metadata">
581
+ <md:IDPSSODescriptor WantAuthnRequestsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
582
+ <md:KeyDescriptor use="signing">
583
+ <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
584
+ <ds:X509Data>
585
+ <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>
586
+ </ds:X509Data>
587
+ </ds:KeyInfo>
588
+ </md:KeyDescriptor>
589
+ <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:8081/api/sso/saml2/idp/redirect"/>
590
+ <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:8081/api/sso/saml2/idp/post"/>
591
+ </md:IDPSSODescriptor>
592
+ </md:EntityDescriptor>
593
+ `;
594
+
595
+ const data = {
596
+ user: [],
597
+ session: [],
598
+ verification: [],
599
+ account: [],
600
+ ssoProvider: [],
601
+ };
602
+
603
+ const memory = memoryAdapter(data);
604
+
605
+ const ssoOptions = {
606
+ defaultSSO: [
607
+ {
608
+ domain: "localhost:8081",
609
+ providerId: "signed-saml",
610
+ samlConfig: {
611
+ issuer: "http://localhost:8081",
612
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
613
+ cert: certificate,
614
+ callbackUrl: "http://localhost:8081/dashboard",
615
+ wantAssertionsSigned: false,
616
+ authnRequestsSigned: true,
617
+ signatureAlgorithm: "sha256",
618
+ digestAlgorithm: "sha256",
619
+ privateKey: idPk,
620
+ spMetadata: {
621
+ privateKey: idPk,
622
+ },
623
+ idpMetadata: {
624
+ metadata: idpMetadataWithSignedRequests,
625
+ },
626
+ identifierFormat:
627
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
628
+ },
629
+ },
630
+ ],
631
+ };
632
+
633
+ const auth = betterAuth({
634
+ database: memory,
635
+ baseURL: "http://localhost:3000",
636
+ emailAndPassword: {
637
+ enabled: true,
638
+ },
639
+ plugins: [sso(ssoOptions)],
640
+ });
641
+
642
+ it("should generate signed AuthnRequest when authnRequestsSigned is true", async () => {
643
+ const signInResponse = await auth.api.signInSSO({
644
+ body: {
645
+ providerId: "signed-saml",
646
+ callbackURL: "http://localhost:3000/dashboard",
647
+ },
648
+ });
649
+
650
+ expect(signInResponse).toEqual({
651
+ url: expect.stringContaining("http://localhost:8081"),
652
+ redirect: true,
653
+ });
654
+ // When authnRequestsSigned is true and privateKey is provided,
655
+ // samlify adds Signature and SigAlg parameters to the redirect URL
656
+ expect(signInResponse.url).toContain("Signature=");
657
+ expect(signInResponse.url).toContain("SigAlg=");
658
+ });
659
+ });
660
+
661
+ describe("SAML SSO without signed AuthnRequests", async () => {
662
+ const data = {
663
+ user: [],
664
+ session: [],
665
+ verification: [],
666
+ account: [],
667
+ ssoProvider: [],
668
+ };
669
+
670
+ const memory = memoryAdapter(data);
671
+
672
+ const ssoOptions = {
673
+ defaultSSO: [
674
+ {
675
+ domain: "localhost:8082",
676
+ providerId: "unsigned-saml",
677
+ samlConfig: {
678
+ issuer: "http://localhost:8082",
679
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
680
+ cert: certificate,
681
+ callbackUrl: "http://localhost:8082/dashboard",
682
+ wantAssertionsSigned: false,
683
+ authnRequestsSigned: false,
684
+ signatureAlgorithm: "sha256",
685
+ digestAlgorithm: "sha256",
686
+ idpMetadata: {
687
+ metadata: idpMetadata,
688
+ },
689
+ spMetadata: {
690
+ metadata: spMetadata,
691
+ },
692
+ identifierFormat:
693
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
694
+ },
695
+ },
696
+ ],
697
+ };
698
+
699
+ const auth = betterAuth({
700
+ database: memory,
701
+ baseURL: "http://localhost:3000",
702
+ emailAndPassword: {
703
+ enabled: true,
704
+ },
705
+ plugins: [sso(ssoOptions)],
706
+ });
707
+
708
+ it("should NOT include Signature in URL when authnRequestsSigned is false", async () => {
709
+ const signInResponse = await auth.api.signInSSO({
710
+ body: {
711
+ providerId: "unsigned-saml",
712
+ callbackURL: "http://localhost:3000/dashboard",
713
+ },
714
+ });
715
+
716
+ expect(signInResponse).toEqual({
717
+ url: expect.stringContaining("http://localhost:8081"),
718
+ redirect: true,
719
+ });
720
+ // When authnRequestsSigned is false (default), no Signature should be in the URL
721
+ expect(signInResponse.url).not.toContain("Signature=");
722
+ expect(signInResponse.url).not.toContain("SigAlg=");
723
+ });
724
+ });
725
+
726
+ describe("SAML SSO with idpMetadata but without metadata XML (fallback to top-level config)", async () => {
727
+ const data = {
728
+ user: [],
729
+ session: [],
730
+ verification: [],
731
+ account: [],
732
+ ssoProvider: [],
733
+ };
734
+
735
+ const memory = memoryAdapter(data);
736
+
737
+ // This tests the fix for signInSSO where IdentityProvider was incorrectly constructed
738
+ // when idpMetadata is provided but without a full metadata XML.
739
+ // The bug was:
740
+ // 1. Using encryptCert instead of signingCert (samlify expects signingCert)
741
+ // 2. Not falling back to parsedSamlConfig.issuer when entityID is missing
742
+ // 3. Not falling back to parsedSamlConfig.entryPoint when singleSignOnService is missing
743
+ const ssoOptions = {
744
+ defaultSSO: [
745
+ {
746
+ domain: "localhost:8083",
747
+ providerId: "partial-idp-metadata-saml",
748
+ samlConfig: {
749
+ issuer: "http://localhost:8083/issuer",
750
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/redirect",
751
+ cert: certificate,
752
+ callbackUrl: "http://localhost:8083/dashboard",
753
+ wantAssertionsSigned: false,
754
+ authnRequestsSigned: false,
755
+ spMetadata: {},
756
+ // idpMetadata is provided but WITHOUT metadata XML - this triggers the fallback path
757
+ // The fix ensures signingCert is used (not encryptCert) and entryPoint/issuer fallbacks work
758
+ idpMetadata: {
759
+ // No metadata XML provided
760
+ // cert could be provided here, but we test fallback to top-level cert
761
+ entityID: "http://localhost:8081/custom-entity-id",
762
+ },
763
+ identifierFormat:
764
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
765
+ },
766
+ },
767
+ ],
768
+ };
769
+
770
+ const auth = betterAuth({
771
+ database: memory,
772
+ baseURL: "http://localhost:3000",
773
+ emailAndPassword: {
774
+ enabled: true,
775
+ },
776
+ plugins: [sso(ssoOptions)],
777
+ });
778
+
779
+ it("should initiate SAML login using fallback entryPoint when idpMetadata has no metadata XML", async () => {
780
+ const signInResponse = await auth.api.signInSSO({
781
+ body: {
782
+ providerId: "partial-idp-metadata-saml",
783
+ callbackURL: "http://localhost:3000/dashboard",
784
+ },
785
+ });
786
+
787
+ // The URL should point to the entryPoint from top-level config (fallback)
788
+ expect(signInResponse).toEqual({
789
+ url: expect.stringContaining(
790
+ "http://localhost:8081/api/sso/saml2/idp/redirect",
791
+ ),
792
+ redirect: true,
793
+ });
794
+ // The URL should contain a SAMLRequest parameter, proving the IdP was constructed correctly
795
+ // with signingCert (not encryptCert) - if encryptCert was used, samlify would fail
796
+ expect(signInResponse.url).toContain("SAMLRequest=");
797
+ });
798
+
799
+ it("should use idpMetadata.entityID when provided (not fall back to issuer)", async () => {
800
+ const signInResponse = await auth.api.signInSSO({
801
+ body: {
802
+ providerId: "partial-idp-metadata-saml",
803
+ callbackURL: "http://localhost:3000/dashboard",
804
+ },
805
+ });
806
+
807
+ // The fact that we get a valid SAMLRequest proves the IdentityProvider
808
+ // was constructed correctly. The entityID from idpMetadata should be used.
809
+ const url = new URL(signInResponse.url);
810
+ const samlRequest = url.searchParams.get("SAMLRequest");
811
+ expect(samlRequest).toBeTruthy();
812
+ });
813
+ });
814
+
577
815
  describe("SAML SSO", async () => {
578
816
  const data = {
579
817
  user: [],
@@ -1859,6 +2097,160 @@ describe("SAML SSO", async () => {
1859
2097
  const redirectLocation = response.headers.get("location") || "";
1860
2098
  expect(redirectLocation).toContain("error=unsolicited_response");
1861
2099
  });
2100
+
2101
+ /**
2102
+ * @see https://github.com/better-auth/better-auth/issues/7777
2103
+ */
2104
+ it("should correctly parse verification-ID-based RelayState on ACS route (SP-initiated)", async () => {
2105
+ const { auth, signInWithTestUser } = await getTestInstance({
2106
+ plugins: [sso()],
2107
+ });
2108
+
2109
+ const { headers } = await signInWithTestUser();
2110
+
2111
+ await auth.api.registerSSOProvider({
2112
+ body: {
2113
+ providerId: "saml-acs-relay-provider",
2114
+ issuer: "http://localhost:8081",
2115
+ domain: "http://localhost:8081",
2116
+ samlConfig: {
2117
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2118
+ cert: certificate,
2119
+ callbackUrl: "http://localhost:3000/dashboard",
2120
+ wantAssertionsSigned: false,
2121
+ signatureAlgorithm: "sha256",
2122
+ digestAlgorithm: "sha256",
2123
+ idpMetadata: {
2124
+ metadata: idpMetadata,
2125
+ },
2126
+ spMetadata: {
2127
+ metadata: spMetadata,
2128
+ },
2129
+ identifierFormat:
2130
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2131
+ },
2132
+ },
2133
+ headers,
2134
+ });
2135
+
2136
+ // SP-initiated: signInSSO returns a URL with a RelayState verification ID
2137
+ const signInRes = await auth.api.signInSSO({
2138
+ body: {
2139
+ providerId: "saml-acs-relay-provider",
2140
+ callbackURL: "http://localhost:3000/dashboard",
2141
+ },
2142
+ returnHeaders: true,
2143
+ });
2144
+
2145
+ const signInResponse = signInRes.response;
2146
+ expect(signInResponse).toEqual({
2147
+ url: expect.stringContaining("http://localhost:8081"),
2148
+ redirect: true,
2149
+ });
2150
+
2151
+ const samlRedirectUrl = new URL(signInResponse?.url);
2152
+ const relayStateParam = samlRedirectUrl.searchParams.get("RelayState");
2153
+ // RelayState should be a verification ID, not a raw URL
2154
+ expect(relayStateParam).toBeTruthy();
2155
+ expect(relayStateParam).not.toContain("http");
2156
+
2157
+ let samlResponse: any;
2158
+ await betterFetch(signInResponse?.url, {
2159
+ onSuccess: async (context) => {
2160
+ samlResponse = await context.data;
2161
+ },
2162
+ });
2163
+
2164
+ // POST to the ACS endpoint with the verification-ID-based RelayState
2165
+ const acsResponse = await auth.handler(
2166
+ new Request(
2167
+ "http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-acs-relay-provider",
2168
+ {
2169
+ method: "POST",
2170
+ headers: {
2171
+ "Content-Type": "application/x-www-form-urlencoded",
2172
+ Cookie: signInRes.headers.get("set-cookie") ?? "",
2173
+ },
2174
+ body: new URLSearchParams({
2175
+ SAMLResponse: samlResponse.samlResponse,
2176
+ RelayState: relayStateParam!,
2177
+ }),
2178
+ },
2179
+ ),
2180
+ );
2181
+
2182
+ expect(acsResponse.status).toBe(302);
2183
+ const acsRedirectLocation = acsResponse.headers.get("location") || "";
2184
+ // Must redirect to the callbackURL from the relay state, not to the verification ID
2185
+ expect(acsRedirectLocation).toContain("dashboard");
2186
+ expect(acsRedirectLocation).not.toContain("error");
2187
+ });
2188
+
2189
+ /**
2190
+ * @see https://github.com/better-auth/better-auth/issues/7777
2191
+ */
2192
+ it("should fallback to provider callbackUrl on ACS route when RelayState is invalid", async () => {
2193
+ const { auth, signInWithTestUser } = await getTestInstance({
2194
+ plugins: [sso()],
2195
+ });
2196
+
2197
+ const { headers } = await signInWithTestUser();
2198
+
2199
+ await auth.api.registerSSOProvider({
2200
+ body: {
2201
+ providerId: "saml-acs-bad-relay-provider",
2202
+ issuer: "http://localhost:8081",
2203
+ domain: "http://localhost:8081",
2204
+ samlConfig: {
2205
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2206
+ cert: certificate,
2207
+ callbackUrl: "http://localhost:3000/dashboard",
2208
+ wantAssertionsSigned: false,
2209
+ signatureAlgorithm: "sha256",
2210
+ digestAlgorithm: "sha256",
2211
+ idpMetadata: {
2212
+ metadata: idpMetadata,
2213
+ },
2214
+ spMetadata: {
2215
+ metadata: spMetadata,
2216
+ },
2217
+ identifierFormat:
2218
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2219
+ },
2220
+ },
2221
+ headers,
2222
+ });
2223
+
2224
+ let samlResponse: any;
2225
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2226
+ onSuccess: async (context) => {
2227
+ samlResponse = await context.data;
2228
+ },
2229
+ });
2230
+
2231
+ // POST with a garbage RelayState - should fallback to provider callbackUrl
2232
+ const acsResponse = await auth.handler(
2233
+ new Request(
2234
+ "http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-acs-bad-relay-provider",
2235
+ {
2236
+ method: "POST",
2237
+ headers: {
2238
+ "Content-Type": "application/x-www-form-urlencoded",
2239
+ },
2240
+ body: new URLSearchParams({
2241
+ SAMLResponse: samlResponse.samlResponse,
2242
+ RelayState: "not-a-valid-relay-state",
2243
+ }),
2244
+ },
2245
+ ),
2246
+ );
2247
+
2248
+ expect(acsResponse.status).toBe(302);
2249
+ const location = acsResponse.headers.get("location") || "";
2250
+ // Should redirect to the provider's callbackUrl, not the garbage RelayState
2251
+ expect(location).toContain("dashboard");
2252
+ expect(location).not.toContain("not-a-valid-relay-state");
2253
+ });
1862
2254
  });
1863
2255
 
1864
2256
  describe("SAML SSO with custom fields", () => {
package/src/types.ts CHANGED
@@ -73,6 +73,7 @@ export interface SAMLConfig {
73
73
  encPrivateKeyPass?: string | undefined;
74
74
  };
75
75
  wantAssertionsSigned?: boolean | undefined;
76
+ authnRequestsSigned?: boolean | undefined;
76
77
  signatureAlgorithm?: string | undefined;
77
78
  digestAlgorithm?: string | undefined;
78
79
  identifierFormat?: string | undefined;
@@ -252,9 +253,12 @@ export interface SSOOptions {
252
253
  */
253
254
  enabled?: boolean;
254
255
  /**
255
- * Prefix used to generate the domain verification token
256
+ * Prefix used to generate the domain verification token.
257
+ * An underscore is automatically prepended to follow DNS
258
+ * infrastructure subdomain conventions (RFC 8552), so do
259
+ * not include a leading underscore.
256
260
  *
257
- * @default "better-auth-token-"
261
+ * @default "better-auth-token"
258
262
  */
259
263
  tokenPrefix?: string;
260
264
  };