@better-auth/sso 1.3.0-beta.1 → 1.3.0-beta.11

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
@@ -10,16 +10,23 @@ import {
10
10
  import { betterAuth } from "better-auth";
11
11
  import { memoryAdapter } from "better-auth/adapters/memory";
12
12
  import { createAuthClient } from "better-auth/client";
13
+ import { betterFetch } from "@better-fetch/fetch";
13
14
  import { setCookieToHeader } from "better-auth/cookies";
14
15
  import { bearer } from "better-auth/plugins";
15
- import { IdentityProvider, ServiceProvider } from "samlify";
16
16
  import { sso } from ".";
17
17
  import { ssoClient } from "./client";
18
18
  import { createServer } from "http";
19
19
  import * as saml from "samlify";
20
+ import type {
21
+ Application as ExpressApp,
22
+ Request as ExpressRequest,
23
+ Response as ExpressResponse,
24
+ } from "express";
25
+ // @ts-ignore
20
26
  import express from "express";
21
27
  import bodyParser from "body-parser";
22
28
  import { randomUUID } from "crypto";
29
+ import { getTestInstanceMemory } from "better-auth/test";
23
30
 
24
31
  const spMetadata = `
25
32
  <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://localhost:3001/api/sso/saml2/sp/metadata">
@@ -346,122 +353,146 @@ const createTemplateCallback =
346
353
  context: saml.SamlLib.replaceTagsByValue(template, tagValues),
347
354
  };
348
355
  };
349
- class MockSAMLIdP {
350
- private app: express.Application;
351
- private server: ReturnType<typeof createServer> | undefined;
352
- private port: number;
353
- private idp: ReturnType<typeof IdentityProvider>;
354
- private sp: ReturnType<typeof ServiceProvider>;
355
- constructor(port: number) {
356
- this.port = port;
357
- this.app = express();
358
- this.app.use(bodyParser.urlencoded({ extended: true }));
359
- this.app.use(bodyParser.json());
360
-
361
- this.idp = IdentityProvider({
362
- metadata: idpMetadata,
363
- privateKey: idPk,
364
- isAssertionEncrypted: false,
365
- privateKeyPass: "jXmKf9By6ruLnUdRo90G",
366
- loginResponseTemplate: {
367
- context:
368
- '<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>',
369
- attributes: [
370
- {
371
- name: "firstName",
372
- valueTag: "firstName",
373
- nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
374
- valueXsiType: "xs:string",
375
- },
376
- {
377
- name: "lastName",
378
- valueTag: "lastName",
379
- nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
380
- valueXsiType: "xs:string",
381
- },
382
- {
383
- name: "email",
384
- valueTag: "email",
385
- nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
386
- valueXsiType: "xs:string",
387
- },
388
- ],
389
- },
390
- });
391
- this.sp = ServiceProvider({
392
- metadata: spMetadata,
393
- });
394
- this.app.get("/api/sso/saml2/idp/post", async (req, res) => {
356
+
357
+ const createMockSAMLIdP = (port: number) => {
358
+ const app: ExpressApp = express();
359
+ let server: ReturnType<typeof createServer> | undefined;
360
+
361
+ app.use(bodyParser.urlencoded({ extended: true }));
362
+ app.use(bodyParser.json());
363
+
364
+ const idp = saml.IdentityProvider({
365
+ metadata: idpMetadata,
366
+ privateKey: idPk,
367
+ isAssertionEncrypted: false,
368
+ privateKeyPass: "jXmKf9By6ruLnUdRo90G",
369
+ loginResponseTemplate: {
370
+ context:
371
+ '<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>',
372
+ attributes: [
373
+ {
374
+ name: "firstName",
375
+ valueTag: "firstName",
376
+ nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
377
+ valueXsiType: "xs:string",
378
+ },
379
+ {
380
+ name: "lastName",
381
+ valueTag: "lastName",
382
+ nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
383
+ valueXsiType: "xs:string",
384
+ },
385
+ {
386
+ name: "email",
387
+ valueTag: "email",
388
+ nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
389
+ valueXsiType: "xs:string",
390
+ },
391
+ ],
392
+ },
393
+ });
394
+ const sp = saml.ServiceProvider({
395
+ metadata: spMetadata,
396
+ });
397
+ app.get(
398
+ "/api/sso/saml2/idp/post",
399
+ async (req: ExpressRequest, res: ExpressResponse) => {
395
400
  const user = { emailAddress: "test@email.com", famName: "hello world" };
396
- const { context, entityEndpoint } = await this.idp.createLoginResponse(
397
- this.sp,
401
+ const { context, entityEndpoint } = await idp.createLoginResponse(
402
+ sp,
398
403
  {} as any,
399
404
  saml.Constants.wording.binding.post,
400
405
  user,
401
- createTemplateCallback(this.idp, this.sp, user.emailAddress),
406
+ createTemplateCallback(idp, sp, user.emailAddress),
402
407
  );
403
408
  res.status(200).send({ samlResponse: context, entityEndpoint });
404
- });
405
- this.app.get("/api/sso/saml2/idp/redirect", async (req, res) => {
409
+ },
410
+ );
411
+ app.get(
412
+ "/api/sso/saml2/idp/redirect",
413
+ async (req: ExpressRequest, res: ExpressResponse) => {
406
414
  const user = { emailAddress: "test@email.com", famName: "hello world" };
407
- const { context, entityEndpoint } = await this.idp.createLoginResponse(
408
- this.sp,
415
+ const { context, entityEndpoint } = await idp.createLoginResponse(
416
+ sp,
409
417
  {} as any,
410
418
  saml.Constants.wording.binding.post,
411
419
  user,
412
- createTemplateCallback(this.idp, this.sp, user.emailAddress),
420
+ createTemplateCallback(idp, sp, user.emailAddress),
413
421
  );
414
422
  res.status(200).send({ samlResponse: context, entityEndpoint });
415
- });
416
- // @ts-ignore
417
- this.app.post("/api/sso/saml2/sp/acs", async (req, res) => {
423
+ },
424
+ );
425
+ app.post("/api/sso/saml2/sp/acs", async (req: any, res: any) => {
426
+ try {
427
+ const parseResult = await sp.parseLoginResponse(
428
+ idp,
429
+ saml.Constants.wording.binding.post,
430
+ req,
431
+ );
432
+ const { extract } = parseResult;
433
+ const { attributes } = extract;
434
+ const relayState = req.body.RelayState;
435
+ if (relayState) {
436
+ return res.status(200).send({ relayState, attributes });
437
+ } else {
438
+ return res
439
+ .status(200)
440
+ .send({ extract, message: "RelayState is missing." });
441
+ }
442
+ } catch (error) {
443
+ console.error("Error handling SAML ACS endpoint:", error);
444
+ res.status(500).send({ error: "Failed to process SAML response." });
445
+ }
446
+ });
447
+ app.post(
448
+ "/api/sso/saml2/callback/:providerId",
449
+ async (req: ExpressRequest, res: ExpressResponse) => {
450
+ const { SAMLResponse, RelayState } = req.body;
418
451
  try {
419
- const parseResult = await this.sp.parseLoginResponse(
420
- this.idp,
452
+ const parseResult = await sp.parseLoginResponse(
453
+ idp,
421
454
  saml.Constants.wording.binding.post,
422
- req,
455
+ { body: { SAMLResponse } },
423
456
  );
424
- const { extract } = parseResult;
425
- const { attributes } = extract;
426
- const relayState = req.body.RelayState;
427
- if (relayState) {
428
- return res.status(200).send({ relayState, attributes });
429
- } else {
430
- return res
431
- .status(200)
432
- .send({ extract, message: "RelayState is missing." });
433
- }
457
+
458
+ const { attributes, nameID } = parseResult.extract;
459
+
460
+ res.redirect(302, RelayState || "http://localhost:3000/dashboard");
434
461
  } catch (error) {
435
- console.error("Error handling SAML ACS endpoint:", error);
436
- res.status(500).send({ error: "Failed to process SAML response." });
462
+ console.error("Error processing SAML callback:", error);
463
+ res.status(500).send({ error: "Failed to process SAML response" });
437
464
  }
438
- });
439
- }
440
-
441
- start() {
442
- return new Promise<void>((resolve) => {
443
- this.app.use(bodyParser.urlencoded({ extended: true }));
444
- this.server = this.app.listen(this.port, () => {
445
- console.log(`Mock SAML IdP running on port ${this.port}`);
465
+ },
466
+ );
467
+ app.get(
468
+ "/api/sso/saml2/idp/metadata",
469
+ (req: ExpressRequest, res: ExpressResponse) => {
470
+ res.type("application/xml");
471
+ res.send(idpMetadata);
472
+ },
473
+ );
474
+ const start = () =>
475
+ new Promise<void>((resolve) => {
476
+ app.use(bodyParser.urlencoded({ extended: true }));
477
+ server = app.listen(port, () => {
478
+ console.log(`Mock SAML IdP running on port ${port}`);
446
479
  resolve();
447
480
  });
448
481
  });
449
- }
450
482
 
451
- stop() {
452
- return new Promise<void>((resolve, reject) => {
453
- this.app.use(bodyParser.urlencoded({ extended: true }));
454
- this.server?.close((err) => {
483
+ const stop = () =>
484
+ new Promise<void>((resolve, reject) => {
485
+ app.use(bodyParser.urlencoded({ extended: true }));
486
+ server?.close((err) => {
455
487
  if (err) reject(err);
456
488
  else resolve();
457
489
  });
458
490
  });
459
- }
460
491
 
461
- get metadataUrl() {
462
- return `http://localhost:${this.port}/idp/metadata`;
463
- }
464
- }
492
+ const metadataUrl = `http://localhost:${port}/idp/metadata`;
493
+
494
+ return { start, stop, metadataUrl };
495
+ };
465
496
 
466
497
  describe("SAML SSO", async () => {
467
498
  const data = {
@@ -473,7 +504,7 @@ describe("SAML SSO", async () => {
473
504
  };
474
505
 
475
506
  const memory = memoryAdapter(data);
476
- const mockIdP = new MockSAMLIdP(8081); // Different port from your main app
507
+ const mockIdP = createMockSAMLIdP(8081); // Different port from your main app
477
508
 
478
509
  const ssoOptions = {
479
510
  provisionUser: vi
@@ -580,7 +611,7 @@ describe("SAML SSO", async () => {
580
611
  encPrivateKeyPass: "g7hGcRmp8PxT5QeP2q9Ehf1bWe9zTALN",
581
612
  },
582
613
  spMetadata: {
583
- metadata: idpMetadata,
614
+ metadata: spMetadata,
584
615
  binding: "post",
585
616
  privateKey: spPrivateKey,
586
617
  privateKeyPass: "VHOSp5RUiBcrsjrcAuXFwU1NKCkGA8px",
@@ -672,9 +703,9 @@ describe("SAML SSO", async () => {
672
703
  issuer: "http://localhost:8081",
673
704
  domain: "http://localhost:8081",
674
705
  samlConfig: {
675
- entryPoint: mockIdP.metadataUrl,
706
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
676
707
  cert: certificate,
677
- callbackUrl: "http://localhost:8081/api/sso/saml2/sp/acs",
708
+ callbackUrl: "http://localhost:8081/dashboard",
678
709
  wantAssertionsSigned: false,
679
710
  signatureAlgorithm: "sha256",
680
711
  digestAlgorithm: "sha256",
@@ -687,9 +718,8 @@ describe("SAML SSO", async () => {
687
718
  encPrivateKeyPass: "g7hGcRmp8PxT5QeP2q9Ehf1bWe9zTALN",
688
719
  },
689
720
  spMetadata: {
690
- metadata: idpMetadata,
721
+ metadata: spMetadata,
691
722
  binding: "post",
692
- // we can do a mapping of property here
693
723
  privateKey: spPrivateKey,
694
724
  privateKeyPass: "VHOSp5RUiBcrsjrcAuXFwU1NKCkGA8px",
695
725
  isAssertionEncrypted: true,
@@ -709,25 +739,180 @@ describe("SAML SSO", async () => {
709
739
  callbackURL: "http://localhost:3000/dashboard",
710
740
  },
711
741
  });
742
+
712
743
  expect(signInResponse).toEqual({
713
744
  url: expect.stringContaining("http://localhost:8081"),
714
745
  redirect: true,
715
746
  });
716
- const loginResponse = await fetch(signInResponse?.url as string);
717
- const resultValue = await loginResponse.json();
718
- const result = await auth.api.callbackSSOSAML({
747
+ let samlResponse: any;
748
+ await betterFetch(signInResponse?.url as string, {
749
+ onSuccess: async (context) => {
750
+ samlResponse = await context.data;
751
+ },
752
+ });
753
+ let redirectLocation = "";
754
+ await betterFetch(
755
+ "http://localhost:8081/api/sso/saml2/callback/saml-provider-1",
756
+ {
757
+ method: "POST",
758
+ redirect: "manual",
759
+ headers: {
760
+ "Content-Type": "application/x-www-form-urlencoded",
761
+ },
762
+ body: new URLSearchParams({
763
+ SAMLResponse: samlResponse.samlResponse,
764
+ }),
765
+ onError: (context) => {
766
+ expect(context.response.status).toBe(302);
767
+ redirectLocation = context.response.headers.get("location") || "";
768
+ },
769
+ },
770
+ );
771
+ expect(redirectLocation).toBe("http://localhost:3000/dashboard");
772
+ });
773
+
774
+ it("should not allow creating a provider if limit is set to 0", async () => {
775
+ const { auth, signInWithTestUser } = await getTestInstanceMemory({
776
+ plugins: [sso({ providersLimit: 0 })],
777
+ });
778
+ const { headers } = await signInWithTestUser();
779
+ await expect(
780
+ auth.api.registerSSOProvider({
781
+ body: {
782
+ providerId: "saml-provider-1",
783
+ issuer: "http://localhost:8081",
784
+ domain: "http://localhost:8081",
785
+ samlConfig: {
786
+ entryPoint: mockIdP.metadataUrl,
787
+ cert: certificate,
788
+ callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
789
+ wantAssertionsSigned: false,
790
+ signatureAlgorithm: "sha256",
791
+ digestAlgorithm: "sha256",
792
+ spMetadata: {
793
+ metadata: spMetadata,
794
+ },
795
+ },
796
+ },
797
+ headers,
798
+ }),
799
+ ).rejects.toMatchObject({
800
+ status: "FORBIDDEN",
801
+ body: { message: "SSO provider registration is disabled" },
802
+ });
803
+ });
804
+
805
+ it("should not allow creating a provider if limit is reached", async () => {
806
+ const { auth, signInWithTestUser } = await getTestInstanceMemory({
807
+ plugins: [sso({ providersLimit: 1 })],
808
+ });
809
+ const { headers } = await signInWithTestUser();
810
+
811
+ await auth.api.registerSSOProvider({
719
812
  body: {
720
- SAMLResponse: resultValue.samlResponse,
721
- RelayState: "http://localhost:3001/dashboard",
813
+ providerId: "saml-provider-1",
814
+ issuer: "http://localhost:8081",
815
+ domain: "http://localhost:8081",
816
+ samlConfig: {
817
+ entryPoint: mockIdP.metadataUrl,
818
+ cert: certificate,
819
+ callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
820
+ wantAssertionsSigned: false,
821
+ signatureAlgorithm: "sha256",
822
+ digestAlgorithm: "sha256",
823
+ spMetadata: {
824
+ metadata: spMetadata,
825
+ },
826
+ },
722
827
  },
723
- params: {
724
- providerId: provider.providerId,
828
+ headers,
829
+ });
830
+
831
+ await expect(
832
+ auth.api.registerSSOProvider({
833
+ body: {
834
+ providerId: "saml-provider-2",
835
+ issuer: "http://localhost:8081",
836
+ domain: "http://localhost:8081",
837
+ samlConfig: {
838
+ entryPoint: mockIdP.metadataUrl,
839
+ cert: certificate,
840
+ callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
841
+ wantAssertionsSigned: false,
842
+ signatureAlgorithm: "sha256",
843
+ digestAlgorithm: "sha256",
844
+ spMetadata: {
845
+ metadata: spMetadata,
846
+ },
847
+ },
848
+ },
849
+ headers,
850
+ }),
851
+ ).rejects.toMatchObject({
852
+ status: "FORBIDDEN",
853
+ body: {
854
+ message: "You have reached the maximum number of SSO providers",
725
855
  },
726
856
  });
857
+ });
727
858
 
728
- expect(result).toEqual({
729
- redirect: true,
730
- url: "http://localhost:3001/dashboard",
859
+ it("should not allow creating a provider if limit from function is reached", async () => {
860
+ const { auth, signInWithTestUser } = await getTestInstanceMemory({
861
+ plugins: [
862
+ sso({
863
+ providersLimit: async (user) => {
864
+ return user.email === "pro@example.com" ? 2 : 1;
865
+ },
866
+ }),
867
+ ],
868
+ });
869
+ const { headers } = await signInWithTestUser();
870
+
871
+ await auth.api.registerSSOProvider({
872
+ body: {
873
+ providerId: "saml-provider-1",
874
+ issuer: "http://localhost:8081",
875
+ domain: "http://localhost:8081",
876
+ samlConfig: {
877
+ entryPoint: mockIdP.metadataUrl,
878
+ cert: certificate,
879
+ callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
880
+ wantAssertionsSigned: false,
881
+ signatureAlgorithm: "sha256",
882
+ digestAlgorithm: "sha256",
883
+ spMetadata: {
884
+ metadata: spMetadata,
885
+ },
886
+ },
887
+ },
888
+ headers,
889
+ });
890
+
891
+ await expect(
892
+ auth.api.registerSSOProvider({
893
+ body: {
894
+ providerId: "saml-provider-2",
895
+ issuer: "http://localhost:8081",
896
+ domain: "http://localhost:8081",
897
+ samlConfig: {
898
+ entryPoint: mockIdP.metadataUrl,
899
+ cert: certificate,
900
+ callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
901
+ wantAssertionsSigned: false,
902
+ signatureAlgorithm: "sha256",
903
+ digestAlgorithm: "sha256",
904
+ spMetadata: {
905
+ metadata: spMetadata,
906
+ },
907
+ },
908
+ },
909
+ headers,
910
+ }),
911
+ ).rejects.toMatchObject({
912
+ status: "FORBIDDEN",
913
+ body: {
914
+ message: "You have reached the maximum number of SSO providers",
915
+ },
731
916
  });
732
917
  });
733
918
  });