@better-auth/sso 1.4.10-beta.1 → 1.4.11-beta.1

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
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { createServer } from "node:http";
3
+ import { base64 } from "@better-auth/utils/base64";
3
4
  import { betterFetch } from "@better-fetch/fetch";
4
5
  import { betterAuth } from "better-auth";
5
6
  import { memoryAdapter } from "better-auth/adapters/memory";
@@ -2035,8 +2036,7 @@ describe("SAML SSO - Signature Validation Security", () => {
2035
2036
  </saml2p:Response>
2036
2037
  `;
2037
2038
 
2038
- const encodedForgedResponse =
2039
- Buffer.from(forgedSamlResponse).toString("base64");
2039
+ const encodedForgedResponse = base64.encode(forgedSamlResponse);
2040
2040
 
2041
2041
  await expect(
2042
2042
  auth.api.callbackSSOSAML({
@@ -2111,9 +2111,7 @@ describe("SAML SSO - Signature Validation Security", () => {
2111
2111
  </saml2p:Response>
2112
2112
  `;
2113
2113
 
2114
- const encodedBadSigResponse = Buffer.from(
2115
- responseWithBadSignature,
2116
- ).toString("base64");
2114
+ const encodedBadSigResponse = base64.encode(responseWithBadSignature);
2117
2115
 
2118
2116
  await expect(
2119
2117
  auth.api.callbackSSOSAML({
@@ -2360,6 +2358,16 @@ describe("SAML SSO - Timestamp Validation", () => {
2360
2358
  });
2361
2359
  });
2362
2360
 
2361
+ describe("SAML SSO - Size Limit Validation", () => {
2362
+ it("should export default size limit constants", async () => {
2363
+ const { DEFAULT_MAX_SAML_RESPONSE_SIZE, DEFAULT_MAX_SAML_METADATA_SIZE } =
2364
+ await import("./constants");
2365
+
2366
+ expect(DEFAULT_MAX_SAML_RESPONSE_SIZE).toBe(256 * 1024);
2367
+ expect(DEFAULT_MAX_SAML_METADATA_SIZE).toBe(100 * 1024);
2368
+ });
2369
+ });
2370
+
2363
2371
  describe("SAML SSO - Assertion Replay Protection", () => {
2364
2372
  it("should reject replayed SAML assertion (same assertion submitted twice)", async () => {
2365
2373
  const { auth, signInWithTestUser } = await getTestInstance({
@@ -2606,3 +2614,351 @@ describe("SAML SSO - Assertion Replay Protection", () => {
2606
2614
  expect(acsLocation).toContain("error=replay_detected");
2607
2615
  });
2608
2616
  });
2617
+
2618
+ describe("SAML SSO - Single Assertion Validation", () => {
2619
+ it("should reject SAML response with multiple assertions on callback endpoint", async () => {
2620
+ const { auth, signInWithTestUser } = await getTestInstance({
2621
+ plugins: [sso()],
2622
+ });
2623
+
2624
+ const { headers } = await signInWithTestUser();
2625
+
2626
+ await auth.api.registerSSOProvider({
2627
+ body: {
2628
+ providerId: "multi-assertion-callback-provider",
2629
+ issuer: "http://localhost:8081",
2630
+ domain: "http://localhost:8081",
2631
+ samlConfig: {
2632
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2633
+ cert: certificate,
2634
+ callbackUrl: "http://localhost:3000/dashboard",
2635
+ wantAssertionsSigned: false,
2636
+ signatureAlgorithm: "sha256",
2637
+ digestAlgorithm: "sha256",
2638
+ idpMetadata: {
2639
+ metadata: idpMetadata,
2640
+ },
2641
+ spMetadata: {
2642
+ metadata: spMetadata,
2643
+ },
2644
+ identifierFormat:
2645
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2646
+ },
2647
+ },
2648
+ headers,
2649
+ });
2650
+
2651
+ const multiAssertionResponse = `
2652
+ <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
2653
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2654
+ <saml2p:Status>
2655
+ <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
2656
+ </saml2p:Status>
2657
+ <saml2:Assertion ID="assertion-1">
2658
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2659
+ <saml2:Subject>
2660
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">legitimate@example.com</saml2:NameID>
2661
+ </saml2:Subject>
2662
+ </saml2:Assertion>
2663
+ <saml2:Assertion ID="assertion-2">
2664
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2665
+ <saml2:Subject>
2666
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker@evil.com</saml2:NameID>
2667
+ </saml2:Subject>
2668
+ </saml2:Assertion>
2669
+ </saml2p:Response>
2670
+ `;
2671
+
2672
+ const encodedResponse = Buffer.from(multiAssertionResponse).toString(
2673
+ "base64",
2674
+ );
2675
+
2676
+ await expect(
2677
+ auth.api.callbackSSOSAML({
2678
+ body: {
2679
+ SAMLResponse: encodedResponse,
2680
+ RelayState: "http://localhost:3000/dashboard",
2681
+ },
2682
+ params: {
2683
+ providerId: "multi-assertion-callback-provider",
2684
+ },
2685
+ }),
2686
+ ).rejects.toMatchObject({
2687
+ body: {
2688
+ code: "SAML_MULTIPLE_ASSERTIONS",
2689
+ },
2690
+ });
2691
+ });
2692
+
2693
+ it("should reject SAML response with multiple assertions on ACS endpoint", async () => {
2694
+ const { auth, signInWithTestUser } = await getTestInstance({
2695
+ plugins: [sso()],
2696
+ });
2697
+
2698
+ const { headers } = await signInWithTestUser();
2699
+
2700
+ await auth.api.registerSSOProvider({
2701
+ body: {
2702
+ providerId: "multi-assertion-acs-provider",
2703
+ issuer: "http://localhost:8081",
2704
+ domain: "http://localhost:8081",
2705
+ samlConfig: {
2706
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2707
+ cert: certificate,
2708
+ callbackUrl: "http://localhost:3000/dashboard",
2709
+ wantAssertionsSigned: false,
2710
+ signatureAlgorithm: "sha256",
2711
+ digestAlgorithm: "sha256",
2712
+ idpMetadata: {
2713
+ metadata: idpMetadata,
2714
+ },
2715
+ spMetadata: {
2716
+ metadata: spMetadata,
2717
+ },
2718
+ identifierFormat:
2719
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2720
+ },
2721
+ },
2722
+ headers,
2723
+ });
2724
+
2725
+ const multiAssertionResponse = `
2726
+ <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
2727
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2728
+ <saml2p:Status>
2729
+ <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
2730
+ </saml2p:Status>
2731
+ <saml2:Assertion ID="assertion-1">
2732
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2733
+ <saml2:Subject>
2734
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">legitimate@example.com</saml2:NameID>
2735
+ </saml2:Subject>
2736
+ </saml2:Assertion>
2737
+ <saml2:Assertion ID="assertion-2">
2738
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2739
+ <saml2:Subject>
2740
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker@evil.com</saml2:NameID>
2741
+ </saml2:Subject>
2742
+ </saml2:Assertion>
2743
+ </saml2p:Response>
2744
+ `;
2745
+
2746
+ const encodedResponse = Buffer.from(multiAssertionResponse).toString(
2747
+ "base64",
2748
+ );
2749
+
2750
+ const response = await auth.handler(
2751
+ new Request(
2752
+ "http://localhost:3000/api/auth/sso/saml2/sp/acs/multi-assertion-acs-provider",
2753
+ {
2754
+ method: "POST",
2755
+ headers: {
2756
+ "Content-Type": "application/x-www-form-urlencoded",
2757
+ },
2758
+ body: new URLSearchParams({
2759
+ SAMLResponse: encodedResponse,
2760
+ RelayState: "http://localhost:3000/dashboard",
2761
+ }),
2762
+ },
2763
+ ),
2764
+ );
2765
+
2766
+ expect(response.status).toBe(302);
2767
+ const location = response.headers.get("location") || "";
2768
+ expect(location).toContain("error=multiple_assertions");
2769
+ });
2770
+
2771
+ it("should reject SAML response with no assertions", async () => {
2772
+ const { auth, signInWithTestUser } = await getTestInstance({
2773
+ plugins: [sso()],
2774
+ });
2775
+
2776
+ const { headers } = await signInWithTestUser();
2777
+
2778
+ await auth.api.registerSSOProvider({
2779
+ body: {
2780
+ providerId: "no-assertion-provider",
2781
+ issuer: "http://localhost:8081",
2782
+ domain: "http://localhost:8081",
2783
+ samlConfig: {
2784
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2785
+ cert: certificate,
2786
+ callbackUrl: "http://localhost:3000/dashboard",
2787
+ wantAssertionsSigned: false,
2788
+ signatureAlgorithm: "sha256",
2789
+ digestAlgorithm: "sha256",
2790
+ idpMetadata: {
2791
+ metadata: idpMetadata,
2792
+ },
2793
+ spMetadata: {
2794
+ metadata: spMetadata,
2795
+ },
2796
+ identifierFormat:
2797
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2798
+ },
2799
+ },
2800
+ headers,
2801
+ });
2802
+
2803
+ const noAssertionResponse = `
2804
+ <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
2805
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2806
+ <saml2p:Status>
2807
+ <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
2808
+ </saml2p:Status>
2809
+ </saml2p:Response>
2810
+ `;
2811
+
2812
+ const encodedResponse = Buffer.from(noAssertionResponse).toString("base64");
2813
+
2814
+ await expect(
2815
+ auth.api.callbackSSOSAML({
2816
+ body: {
2817
+ SAMLResponse: encodedResponse,
2818
+ RelayState: "http://localhost:3000/dashboard",
2819
+ },
2820
+ params: {
2821
+ providerId: "no-assertion-provider",
2822
+ },
2823
+ }),
2824
+ ).rejects.toMatchObject({
2825
+ body: {
2826
+ code: "SAML_NO_ASSERTION",
2827
+ },
2828
+ });
2829
+ });
2830
+
2831
+ it("should reject SAML response with XSW-style assertion injection in Extensions", async () => {
2832
+ const { auth, signInWithTestUser } = await getTestInstance({
2833
+ plugins: [sso()],
2834
+ });
2835
+
2836
+ const { headers } = await signInWithTestUser();
2837
+
2838
+ await auth.api.registerSSOProvider({
2839
+ body: {
2840
+ providerId: "xsw-injection-provider",
2841
+ issuer: "http://localhost:8081",
2842
+ domain: "http://localhost:8081",
2843
+ samlConfig: {
2844
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2845
+ cert: certificate,
2846
+ callbackUrl: "http://localhost:3000/dashboard",
2847
+ wantAssertionsSigned: false,
2848
+ signatureAlgorithm: "sha256",
2849
+ digestAlgorithm: "sha256",
2850
+ idpMetadata: {
2851
+ metadata: idpMetadata,
2852
+ },
2853
+ spMetadata: {
2854
+ metadata: spMetadata,
2855
+ },
2856
+ identifierFormat:
2857
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2858
+ },
2859
+ },
2860
+ headers,
2861
+ });
2862
+
2863
+ const xswInjectionResponse = `
2864
+ <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
2865
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2866
+ <saml2p:Status>
2867
+ <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
2868
+ </saml2p:Status>
2869
+ <saml2p:Extensions>
2870
+ <saml2:Assertion ID="injected-assertion">
2871
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2872
+ <saml2:Subject>
2873
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker@evil.com</saml2:NameID>
2874
+ </saml2:Subject>
2875
+ </saml2:Assertion>
2876
+ </saml2p:Extensions>
2877
+ <saml2:Assertion ID="legitimate-assertion">
2878
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2879
+ <saml2:Subject>
2880
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">user@example.com</saml2:NameID>
2881
+ </saml2:Subject>
2882
+ </saml2:Assertion>
2883
+ </saml2p:Response>
2884
+ `;
2885
+
2886
+ const encodedResponse =
2887
+ Buffer.from(xswInjectionResponse).toString("base64");
2888
+
2889
+ await expect(
2890
+ auth.api.callbackSSOSAML({
2891
+ body: {
2892
+ SAMLResponse: encodedResponse,
2893
+ RelayState: "http://localhost:3000/dashboard",
2894
+ },
2895
+ params: {
2896
+ providerId: "xsw-injection-provider",
2897
+ },
2898
+ }),
2899
+ ).rejects.toMatchObject({
2900
+ body: {
2901
+ code: "SAML_MULTIPLE_ASSERTIONS",
2902
+ },
2903
+ });
2904
+ });
2905
+
2906
+ it("should accept valid SAML response with exactly one assertion", async () => {
2907
+ const { auth, signInWithTestUser } = await getTestInstance({
2908
+ plugins: [sso()],
2909
+ });
2910
+
2911
+ const { headers } = await signInWithTestUser();
2912
+
2913
+ await auth.api.registerSSOProvider({
2914
+ body: {
2915
+ providerId: "single-assertion-provider",
2916
+ issuer: "http://localhost:8081",
2917
+ domain: "http://localhost:8081",
2918
+ samlConfig: {
2919
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2920
+ cert: certificate,
2921
+ callbackUrl: "http://localhost:3000/dashboard",
2922
+ wantAssertionsSigned: false,
2923
+ signatureAlgorithm: "sha256",
2924
+ digestAlgorithm: "sha256",
2925
+ idpMetadata: {
2926
+ metadata: idpMetadata,
2927
+ },
2928
+ spMetadata: {
2929
+ metadata: spMetadata,
2930
+ },
2931
+ identifierFormat:
2932
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2933
+ },
2934
+ },
2935
+ headers,
2936
+ });
2937
+
2938
+ let samlResponse: any;
2939
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2940
+ onSuccess: async (context) => {
2941
+ samlResponse = await context.data;
2942
+ },
2943
+ });
2944
+
2945
+ const response = await auth.handler(
2946
+ new Request(
2947
+ "http://localhost:3000/api/auth/sso/saml2/callback/single-assertion-provider",
2948
+ {
2949
+ method: "POST",
2950
+ headers: {
2951
+ "Content-Type": "application/x-www-form-urlencoded",
2952
+ },
2953
+ body: new URLSearchParams({
2954
+ SAMLResponse: samlResponse.samlResponse,
2955
+ RelayState: "http://localhost:3000/dashboard",
2956
+ }),
2957
+ },
2958
+ ),
2959
+ );
2960
+
2961
+ expect(response.status).toBe(302);
2962
+ expect(response.headers.get("location")).not.toContain("error");
2963
+ });
2964
+ });
package/src/types.ts CHANGED
@@ -341,5 +341,17 @@ export interface SSOOptions {
341
341
  * ```
342
342
  */
343
343
  algorithms?: AlgorithmValidationOptions;
344
+ /**
345
+ * Maximum allowed size for SAML responses in bytes.
346
+ *
347
+ * @default 262144 (256KB)
348
+ */
349
+ maxResponseSize?: number;
350
+ /**
351
+ * Maximum allowed size for IdP metadata XML in bytes.
352
+ *
353
+ * @default 102400 (100KB)
354
+ */
355
+ maxMetadataSize?: number;
344
356
  };
345
357
  }