Repository: supabase/cli
Pull request #879 (2023-02-21 14:26:22)

feat: add `sso` commands
Commands to manage Single Sign-On authentication on projects.

coveralls: ## Pull Request Test Coverage Report for [Build 4233661953](https://coveralls.io/builds/57139020)

* **43** of **75**   **(57.33%)**  changed or added relevant lines in **2** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage decreased (**-0.05%**) to **62.429%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [cmd/gen.go](https://coveralls.io/builds/57139020/source?filename=cmd%2Fgen.go#L36) | 1 | 3 | 33.33%
| [cmd/sso.go](https://coveralls.io/builds/57139020/source?filename=cmd%2Fsso.go#L27) | 42 | 72 | 58.33%
<!-- | **Total:** | **43** | **75** | **57.33%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/57139020/badge)](https://coveralls.io/builds/57139020) |
| :-- | --: |
| Change from base [Build 4220770754](https://coveralls.io/builds/57087448): |  -0.05% |
| Covered Lines: | 3752 |
| Relevant Lines: | 6010 |

---
##### 💛  - [Coveralls](https://coveralls.io)

hf: @kangmingtay can't reply to that comment not sure why, so here it is:
> i dont think this defaults to saml - if it's excluded, i get the following error: Error: accepts 1 arg(s), received 0

This is intentional, the user has to enter `saml` or in the future `oidc` to explicitly specify the type.


Patch:
diff --git a/.gitignore b/.gitignore
index de7897e8..53452307 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
 *.dll
 *.so
 *.dylib
+/cli
 
 # Test binary, built with `go test -c`
 *.test
@@ -20,3 +21,4 @@
 # NPM
 node_modules
 package-lock.json
+
diff --git a/api/beta.yaml b/api/beta.yaml
index f1f74615..c9260ff9 100644
--- a/api/beta.yaml
+++ b/api/beta.yaml
@@ -1130,10 +1130,10 @@ paths:
           description: Failed to disable project's readonly mode
       tags: *ref_28
       security: *ref_29
-  /v1/projects/{ref}/config/auth/sso/providers/{provider_id}:
-    get:
-      operationId: getProviderById
-      summary: Gets a SSO provider by its UUID
+  /v1/projects/{ref}/config/auth/sso/providers:
+    post:
+      operationId: createProviderForProject
+      summary: Creates a new SSO provider
       parameters:
         - name: ref
           required: true
@@ -1143,27 +1143,30 @@ paths:
             minLength: 20
             maxLength: 20
             type: string
-        - name: provider_id
-          required: true
-          in: path
-          schema:
-            type: string
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/CreateProviderBody'
       responses:
-        '200':
+        '201':
           description: ''
           content:
             application/json:
               schema:
-                $ref: '#/components/schemas/GetProviderResponse'
+                $ref: '#/components/schemas/CreateProviderResponse'
         '403':
           description: ''
+        '404':
+          description: SAML 2.0 support is not enabled for this project
       tags: &ref_30
         - sso
       security: &ref_31
         - bearer: []
-    put:
-      operationId: updateProviderById
-      summary: Updates a SSO provider by its UUID
+    get:
+      operationId: listAllProviders
+      summary: Lists all SSO providers
       parameters:
         - name: ref
           required: true
@@ -1173,31 +1176,23 @@ paths:
             minLength: 20
             maxLength: 20
             type: string
-        - name: provider_id
-          required: true
-          in: path
-          schema:
-            type: string
-      requestBody:
-        required: true
-        content:
-          application/json:
-            schema:
-              $ref: '#/components/schemas/UpdateProviderBody'
       responses:
         '200':
           description: ''
           content:
             application/json:
               schema:
-                $ref: '#/components/schemas/UpdateProviderResponse'
+                $ref: '#/components/schemas/ListProvidersResponse'
         '403':
           description: ''
+        '404':
+          description: SAML 2.0 support is not enabled for this project
       tags: *ref_30
       security: *ref_31
-    delete:
-      operationId: removeProviderById
-      summary: Removes a SSO provider by its UUID
+  /v1/projects/{ref}/config/auth/sso/providers/{provider_id}:
+    get:
+      operationId: getProviderById
+      summary: Gets a SSO provider by its UUID
       parameters:
         - name: ref
           required: true
@@ -1218,15 +1213,18 @@ paths:
           content:
             application/json:
               schema:
-                $ref: '#/components/schemas/DeleteProviderResponse'
+                $ref: '#/components/schemas/GetProviderResponse'
         '403':
           description: ''
+        '404':
+          description: >-
+            Either SAML 2.0 was not enabled for this project, or the provider
+            does not exist
       tags: *ref_30
       security: *ref_31
-  /v1/projects/{ref}/config/auth/sso/providers:
-    post:
-      operationId: createProviderForProject
-      summary: Creates a new SSO provider
+    put:
+      operationId: updateProviderById
+      summary: Updates a SSO provider by its UUID
       parameters:
         - name: ref
           required: true
@@ -1236,26 +1234,35 @@ paths:
             minLength: 20
             maxLength: 20
             type: string
+        - name: provider_id
+          required: true
+          in: path
+          schema:
+            type: string
       requestBody:
         required: true
         content:
           application/json:
             schema:
-              $ref: '#/components/schemas/CreateProviderBody'
+              $ref: '#/components/schemas/UpdateProviderBody'
       responses:
-        '201':
+        '200':
           description: ''
           content:
             application/json:
               schema:
-                $ref: '#/components/schemas/CreateProviderResponse'
+                $ref: '#/components/schemas/UpdateProviderResponse'
         '403':
           description: ''
+        '404':
+          description: >-
+            Either SAML 2.0 was not enabled for this project, or the provider
+            does not exist
       tags: *ref_30
       security: *ref_31
-    get:
-      operationId: listAllProviders
-      summary: Lists all SSO providers
+    delete:
+      operationId: removeProviderById
+      summary: Removes a SSO provider by its UUID
       parameters:
         - name: ref
           required: true
@@ -1265,15 +1272,24 @@ paths:
             minLength: 20
             maxLength: 20
             type: string
+        - name: provider_id
+          required: true
+          in: path
+          schema:
+            type: string
       responses:
         '200':
           description: ''
           content:
             application/json:
               schema:
-                $ref: '#/components/schemas/ListProvidersResponse'
+                $ref: '#/components/schemas/DeleteProviderResponse'
         '403':
           description: ''
+        '404':
+          description: >-
+            Either SAML 2.0 was not enabled for this project, or the provider
+            does not exist
       tags: *ref_30
       security: *ref_31
 info:
@@ -1815,7 +1831,11 @@ components:
           items:
             type: string
         default:
-          type: object
+          oneOf:
+            - type: object
+            - type: number
+            - type: string
+            - type: boolean
     AttributeMapping:
       type: object
       properties:
@@ -1825,15 +1845,42 @@ components:
             $ref: '#/components/schemas/AttributeValue'
       required:
         - keys
+    CreateProviderBody:
+      type: object
+      properties:
+        type:
+          type: string
+          enum:
+            - saml
+          description: What type of provider will be created
+        metadata_xml:
+          type: string
+        metadata_url:
+          type: string
+        domains:
+          type: array
+          items:
+            type: string
+        attribute_mapping:
+          $ref: '#/components/schemas/AttributeMapping'
+      required:
+        - type
     SamlDescriptor:
       type: object
       properties:
+        id:
+          type: string
+        entity_id:
+          type: string
         metadata_url:
           type: string
         metadata_xml:
           type: string
         attribute_mapping:
           $ref: '#/components/schemas/AttributeMapping'
+      required:
+        - id
+        - entity_id
     Domain:
       type: object
       properties:
@@ -1847,7 +1894,7 @@ components:
           type: string
       required:
         - id
-    GetProviderResponse:
+    CreateProviderResponse:
       type: object
       properties:
         id:
@@ -1864,20 +1911,7 @@ components:
           type: string
       required:
         - id
-    UpdateProviderBody:
-      type: object
-      properties:
-        metadata_xml:
-          type: string
-        metadata_url:
-          type: string
-        domains:
-          type: array
-          items:
-            type: string
-        attribute_mapping:
-          $ref: '#/components/schemas/AttributeMapping'
-    UpdateProviderResponse:
+    Provider:
       type: object
       properties:
         id:
@@ -1894,7 +1928,16 @@ components:
           type: string
       required:
         - id
-    DeleteProviderResponse:
+    ListProvidersResponse:
+      type: object
+      properties:
+        items:
+          type: array
+          items:
+            $ref: '#/components/schemas/Provider'
+      required:
+        - items
+    GetProviderResponse:
       type: object
       properties:
         id:
@@ -1911,14 +1954,9 @@ components:
           type: string
       required:
         - id
-    CreateProviderBody:
+    UpdateProviderBody:
       type: object
       properties:
-        type:
-          type: string
-          enum:
-            - saml
-          description: What type of provider will be created
         metadata_xml:
           type: string
         metadata_url:
@@ -1929,9 +1967,7 @@ components:
             type: string
         attribute_mapping:
           $ref: '#/components/schemas/AttributeMapping'
-      required:
-        - type
-    CreateProviderResponse:
+    UpdateProviderResponse:
       type: object
       properties:
         id:
@@ -1948,7 +1984,7 @@ components:
           type: string
       required:
         - id
-    Provider:
+    DeleteProviderResponse:
       type: object
       properties:
         id:
@@ -1965,12 +2001,3 @@ components:
           type: string
       required:
         - id
-    ListProvidersResponse:
-      type: object
-      properties:
-        items:
-          type: array
-          items:
-            $ref: '#/components/schemas/Provider'
-      required:
-        - items
diff --git a/cmd/functions.go b/cmd/functions.go
index 2d10db49..52fc3445 100644
--- a/cmd/functions.go
+++ b/cmd/functions.go
@@ -72,7 +72,7 @@ var (
 			if err := PromptLogin(fsys); err != nil {
 				return err
 			}
-			if err := PromptProjectRef(fsys); err != nil {
+			if err := PromptProjectRef(fsys, cmd); err != nil {
 				return err
 			}
 			ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt)
@@ -145,7 +145,9 @@ func PromptLogin(fsys afero.Fs) error {
 	}
 }
 
-func PromptProjectRef(fsys afero.Fs) error {
+// PromptProjectRef prompts for the project ref if there is no project ref provided
+// or if there is no linked project
+func PromptProjectRef(fsys afero.Fs, cmd *cobra.Command) error {
 	if len(projectRef) > 0 {
 		return nil
 	} else if err := utils.AssertIsLinkedFS(fsys); err == nil {
@@ -156,7 +158,7 @@ Enter your project ref: `, utils.GetSupabaseDashboardURL())
 
 		scanner := bufio.NewScanner(os.Stdin)
 		if !scanner.Scan() {
-			return errors.New("Cancelled " + utils.Aqua("supabase functions deploy") + ".")
+			return errors.New("Cancelled " + utils.Aqua(cmd.UseLine()) + ".")
 		}
 
 		projectRef = strings.TrimSpace(scanner.Text())
diff --git a/cmd/sso.go b/cmd/sso.go
new file mode 100644
index 00000000..d5e2f8a4
--- /dev/null
+++ b/cmd/sso.go
@@ -0,0 +1,214 @@
+package cmd
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"os/signal"
+
+	"github.com/spf13/afero"
+	"github.com/spf13/cobra"
+
+	"github.com/supabase/cli/internal/sso/create"
+	"github.com/supabase/cli/internal/sso/get"
+	"github.com/supabase/cli/internal/sso/info"
+	"github.com/supabase/cli/internal/sso/list"
+	"github.com/supabase/cli/internal/sso/remove"
+	"github.com/supabase/cli/internal/sso/update"
+	"github.com/supabase/cli/internal/utils"
+)
+
+var (
+	ssoCmd = &cobra.Command{
+		GroupID: groupManagementAPI,
+		Use:     "sso",
+		Short:   "Manage Single Sign-On (SSO) authentication for projects",
+		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+			if err := cmd.Root().PersistentPreRunE(cmd, args); err != nil {
+				return err
+			}
+			ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt)
+			cmd.SetContext(ctx)
+
+			fsys := afero.NewOsFs()
+			if err := PromptProjectRef(fsys, cmd); err != nil {
+				return err
+			}
+
+			// Validate project ref
+			if !utils.ProjectRefPattern.MatchString(projectRef) {
+				return errors.New("Invalid project ref format. Must be like `abcdefghijklmnopqrst`.")
+			}
+			return nil
+		},
+	}
+
+	ssoProviderType = utils.EnumFlag{
+		Allowed: []string{"saml"},
+		// intentionally no default value so users have to specify --type saml explicitly
+	}
+	ssoMetadataFile         string
+	ssoMetadataURL          string
+	ssoSkipURLValidation    bool
+	ssoMetadata             bool
+	ssoAttributeMappingFile string
+	ssoDomains              []string
+	ssoAddDomains           []string
+	ssoRemoveDomains        []string
+	ssoOutput               = utils.EnumFlag{
+		Allowed: utils.OutputDefaultAllowed,
+		Value:   utils.OutputPretty,
+	}
+
+	ssoAddCmd = &cobra.Command{
+		Use:     "add",
+		Short:   "Add a new SSO identity provider",
+		Example: `  supabase sso add --type saml --project-ref mwjylndxudmiehsxhmmz --metadata-file ~/SAMLMetadata.xml --domains example.com`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return create.Run(cmd.Context(), create.RunParams{
+				ProjectRef: projectRef,
+				Format:     ssoOutput.Value,
+
+				MetadataFile:      ssoMetadataFile,
+				MetadataURL:       ssoMetadataURL,
+				SkipURLValidation: ssoSkipURLValidation,
+				AttributeMapping:  ssoAttributeMappingFile,
+				Domains:           ssoDomains,
+			})
+		},
+	}
+
+	ssoRemoveCmd = &cobra.Command{
+		Use:     "remove <provider-id>",
+		Short:   "Remove an existing SSO identity provider",
+		Args:    cobra.ExactArgs(1),
+		Example: `  supabase sso remove b5ae62f9-ef1d-4f11-a02b-731c8bbb11e8 --project-ref mwjylndxudmiehsxhmmz`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if !utils.UUIDPattern.MatchString(args[0]) {
+				return fmt.Errorf("Identity provider ID %q is not a UUID.", args[0])
+			}
+
+			return remove.Run(cmd.Context(), projectRef, args[0], ssoOutput.Value)
+		},
+	}
+
+	ssoUpdateCmd = &cobra.Command{
+		Use:     "update <provider-id>",
+		Short:   "Update information about an SSO identity provider",
+		Args:    cobra.ExactArgs(1),
+		Example: `  supabase sso update b5ae62f9-ef1d-4f11-a02b-731c8bbb11e8 --project-ref mwjylndxudmiehsxhmmz --add-domain example.com`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if !utils.UUIDPattern.MatchString(args[0]) {
+				return fmt.Errorf("Identity provider ID %q is not a UUID.", args[0])
+			}
+
+			return update.Run(cmd.Context(), update.RunParams{
+				ProjectRef: projectRef,
+				ProviderID: args[0],
+				Format:     ssoOutput.Value,
+
+				MetadataFile:      ssoMetadataFile,
+				MetadataURL:       ssoMetadataURL,
+				SkipURLValidation: ssoSkipURLValidation,
+				AttributeMapping:  ssoAttributeMappingFile,
+				Domains:           ssoDomains,
+				AddDomains:        ssoAddDomains,
+				RemoveDomains:     ssoRemoveDomains,
+			})
+		},
+	}
+
+	ssoShowCmd = &cobra.Command{
+		Use:     "show <provider-id>",
+		Short:   "Show information about an SSO identity provider",
+		Args:    cobra.ExactArgs(1),
+		Example: `  supabase sso show b5ae62f9-ef1d-4f11-a02b-731c8bbb11e8 --project-ref mwjylndxudmiehsxhmmz`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if !utils.UUIDPattern.MatchString(args[0]) {
+				return fmt.Errorf("Identity provider ID %q is not a UUID.", args[0])
+			}
+
+			format := ssoOutput.Value
+			if ssoMetadata {
+				format = utils.OutputMetadata
+			}
+
+			return get.Run(cmd.Context(), projectRef, args[0], format)
+		},
+	}
+
+	ssoListCmd = &cobra.Command{
+		Use:     "list",
+		Short:   "List all SSO identity providers for a project",
+		Example: `  supabase sso list --project-ref mwjylndxudmiehsxhmmz`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return list.Run(cmd.Context(), projectRef, ssoOutput.Value)
+		},
+	}
+
+	ssoInfoCmd = &cobra.Command{
+		Use:     "info",
+		Short:   "Returns the SAML SSO settings required for the identity provider",
+		Example: `  supabase sso info --project-ref mwjylndxudmiehsxhmmz`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return info.Run(cmd.Context(), projectRef, ssoOutput.Value)
+		},
+	}
+)
+
+func init() {
+	ssoAddFlags := ssoAddCmd.Flags()
+	ssoAddFlags.VarP(&ssoProviderType, "type", "t", "Type of identity provider (according to supported protocol).")
+	ssoAddFlags.VarP(&ssoOutput, "output", "o", "Output format")
+	ssoAddFlags.StringVar(&projectRef, "project-ref", "", "Project on which to add this identity provider.")
+	ssoAddFlags.StringSliceVar(&ssoDomains, "domains", nil, "Comma separated list of email domains to associate with the added identity provider.")
+	ssoAddFlags.StringVar(&ssoMetadataFile, "metadata-file", "", "File containing a SAML 2.0 Metadata XML document describing the identity provider.")
+	ssoAddFlags.StringVar(&ssoMetadataURL, "metadata-url", "", "URL pointing to a SAML 2.0 Metadata XML document describing the identity provider.")
+	ssoAddFlags.BoolVar(&ssoSkipURLValidation, "skip-url-validation", false, "Whether local validation of the SAML 2.0 Metadata URL should not be performed.")
+	ssoAddFlags.StringVar(&ssoAttributeMappingFile, "attribute-mapping-file", "", "File containing a JSON mapping between SAML attributes to custom JWT claims.")
+	ssoAddCmd.MarkFlagsMutuallyExclusive("metadata-file", "metadata-url")
+	cobra.CheckErr(ssoAddCmd.MarkFlagFilename("metadata-file", "xml"))
+	cobra.CheckErr(ssoAddCmd.MarkFlagFilename("attribute-mapping-file", "json"))
+
+	ssoRemoveFlags := ssoRemoveCmd.Flags()
+	ssoRemoveFlags.VarP(&ssoOutput, "output", "o", "Output format")
+	ssoRemoveFlags.StringVar(&projectRef, "project-ref", "", "Project on which to remove this identity provider.")
+
+	ssoUpdateFlags := ssoUpdateCmd.Flags()
+	ssoUpdateFlags.VarP(&ssoOutput, "output", "o", "Output format")
+	ssoUpdateFlags.StringVar(&projectRef, "project-ref", "", "Project on which to update this identity provider.")
+	ssoUpdateFlags.StringSliceVar(&ssoDomains, "domains", []string{}, "Replace domains with this comma separated list of email domains.")
+	ssoUpdateFlags.StringSliceVar(&ssoAddDomains, "add-domains", []string{}, "Add this comma separated list of email domains to the identity provider.")
+	ssoUpdateFlags.StringSliceVar(&ssoRemoveDomains, "remove-domains", []string{}, "Remove this comma separated list of email domains from the identity provider.")
+	ssoUpdateFlags.StringVar(&ssoMetadataFile, "metadata-file", "", "File containing a SAML 2.0 Metadata XML document describing the identity provider.")
+	ssoUpdateFlags.StringVar(&ssoMetadataURL, "metadata-url", "", "URL pointing to a SAML 2.0 Metadata XML document describing the identity provider.")
+	ssoUpdateFlags.BoolVar(&ssoSkipURLValidation, "skip-url-validation", false, "Whether local validation of the SAML 2.0 Metadata URL should not be performed.")
+	ssoUpdateFlags.StringVar(&ssoAttributeMappingFile, "attribute-mapping-file", "", "File containing a JSON mapping between SAML attributes to custom JWT claims.")
+	ssoUpdateCmd.MarkFlagsMutuallyExclusive("metadata-file", "metadata-url")
+	ssoUpdateCmd.MarkFlagsMutuallyExclusive("domains", "add-domains")
+	ssoUpdateCmd.MarkFlagsMutuallyExclusive("domains", "remove-domains")
+	cobra.CheckErr(ssoUpdateCmd.MarkFlagFilename("metadata-file", "xml"))
+	cobra.CheckErr(ssoUpdateCmd.MarkFlagFilename("attribute-mapping-file", "json"))
+
+	ssoShowFlags := ssoShowCmd.Flags()
+	ssoShowFlags.VarP(&ssoOutput, "output", "o", "Output format")
+	ssoShowFlags.StringVar(&projectRef, "project-ref", "", "Project on which to get this identity provider.")
+	ssoShowFlags.BoolVar(&ssoMetadata, "metadata", false, "Show SAML 2.0 XML Metadata only")
+
+	ssoListFlags := ssoListCmd.Flags()
+	ssoListFlags.VarP(&ssoOutput, "output", "o", "Output format")
+	ssoListFlags.StringVar(&projectRef, "project-ref", "", "Project on which to list identity providers.")
+
+	ssoInfoFlags := ssoInfoCmd.Flags()
+	ssoInfoFlags.VarP(&ssoOutput, "output", "o", "Output format")
+	ssoInfoFlags.StringVar(&projectRef, "project-ref", "", "Project on which to show project SAML SSO settings.")
+
+	ssoCmd.AddCommand(ssoAddCmd)
+	ssoCmd.AddCommand(ssoRemoveCmd)
+	ssoCmd.AddCommand(ssoUpdateCmd)
+	ssoCmd.AddCommand(ssoShowCmd)
+	ssoCmd.AddCommand(ssoListCmd)
+	ssoCmd.AddCommand(ssoInfoCmd)
+
+	rootCmd.AddCommand(ssoCmd)
+}
diff --git a/cmd/status.go b/cmd/status.go
index 2ee542f9..5f131c90 100644
--- a/cmd/status.go
+++ b/cmd/status.go
@@ -15,16 +15,9 @@ var (
 	override []string
 	names    status.CustomName
 	output   = utils.EnumFlag{
-		Allowed: []string{
-			status.OutputEnv,
-			status.OutputJson,
-			status.OutputPretty,
-			status.OutputToml,
-			status.OutputYaml,
-		},
-		Value: status.OutputPretty,
+		Allowed: append([]string{utils.OutputEnv}, utils.OutputDefaultAllowed...),
+		Value:   utils.OutputPretty,
 	}
-
 	statusCmd = &cobra.Command{
 		GroupID: groupLocalDev,
 		Use:     "status",
diff --git a/go.mod b/go.mod
index 21925783..6cb54bc7 100644
--- a/go.mod
+++ b/go.mod
@@ -41,6 +41,7 @@ require (
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/go-playground/validator/v10 v10.11.2 // indirect
+	github.com/go-xmlfmt/xmlfmt v1.1.2 // indirect
 	github.com/goccy/go-json v0.10.0 // indirect
 	github.com/godbus/dbus/v5 v5.1.0 // indirect
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
diff --git a/go.sum b/go.sum
index a3bc5399..ca7c2686 100644
--- a/go.sum
+++ b/go.sum
@@ -155,6 +155,8 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91
 github.com/go-playground/validator/v10 v10.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU=
 github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/go-xmlfmt/xmlfmt v1.1.2 h1:Nea7b4icn8s57fTx1M5AI4qQT5HEM3rVUO8MuE6g80U=
+github.com/go-xmlfmt/xmlfmt v1.1.2/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
 github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA=
 github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
 github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
diff --git a/internal/sso/create/create.go b/internal/sso/create/create.go
new file mode 100644
index 00000000..c9246d1a
--- /dev/null
+++ b/internal/sso/create/create.go
@@ -0,0 +1,90 @@
+package create
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"os"
+
+	"github.com/spf13/afero"
+
+	"github.com/supabase/cli/internal/sso/internal/render"
+	"github.com/supabase/cli/internal/sso/internal/saml"
+	"github.com/supabase/cli/internal/utils"
+	"github.com/supabase/cli/pkg/api"
+)
+
+var Fs = afero.NewOsFs()
+
+type RunParams struct {
+	ProjectRef string
+	Format     string
+
+	Domains           []string
+	MetadataFile      string
+	MetadataURL       string
+	SkipURLValidation bool
+	AttributeMapping  string
+}
+
+func Run(ctx context.Context, params RunParams) error {
+	var body api.CreateProviderForProjectJSONRequestBody
+
+	if params.MetadataFile != "" {
+		data, err := saml.ReadMetadataFile(Fs, params.MetadataFile)
+		if err != nil {
+			return err
+		}
+
+		body.MetadataXml = &data
+	} else if params.MetadataURL != "" {
+		if !params.SkipURLValidation {
+			if err := saml.ValidateMetadataURL(ctx, params.MetadataURL); err != nil {
+				return fmt.Errorf("%w Use --skip-url-validation to suppress this error.", err)
+			}
+		}
+
+		body.MetadataUrl = &params.MetadataURL
+	}
+
+	if params.AttributeMapping != "" {
+		data, err := saml.ReadAttributeMappingFile(Fs, params.AttributeMapping)
+		if err != nil {
+			return err
+		}
+
+		body.AttributeMapping = data
+	}
+
+	if params.Domains != nil {
+		body.Domains = &params.Domains
+	}
+
+	resp, err := utils.GetSupabase().CreateProviderForProjectWithResponse(ctx, params.ProjectRef, body)
+	if err != nil {
+		return err
+	}
+
+	if resp.JSON201 == nil {
+		if resp.StatusCode() == http.StatusNotFound {
+			return errors.New("Looks like SAML 2.0 support is not enabled for this project. Please use the dashboard to enable it.")
+		}
+
+		return errors.New("Unexpected error adding identity provider: " + string(resp.Body))
+	}
+
+	switch params.Format {
+	case utils.OutputPretty:
+		return render.SingleMarkdown(api.Provider{
+			Id:        resp.JSON201.Id,
+			Saml:      resp.JSON201.Saml,
+			Domains:   resp.JSON201.Domains,
+			CreatedAt: resp.JSON201.CreatedAt,
+			UpdatedAt: resp.JSON201.UpdatedAt,
+		})
+
+	default:
+		return utils.EncodeOutput(params.Format, os.Stdout, resp.JSON201)
+	}
+}
diff --git a/internal/sso/get/get.go b/internal/sso/get/get.go
new file mode 100644
index 00000000..fadc5848
--- /dev/null
+++ b/internal/sso/get/get.go
@@ -0,0 +1,46 @@
+package get
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"os"
+
+	"github.com/supabase/cli/internal/sso/internal/render"
+	"github.com/supabase/cli/internal/utils"
+	"github.com/supabase/cli/pkg/api"
+)
+
+func Run(ctx context.Context, ref, providerId, format string) error {
+	resp, err := utils.GetSupabase().GetProviderByIdWithResponse(ctx, ref, providerId)
+	if err != nil {
+		return err
+	}
+
+	if resp.JSON200 == nil {
+		if resp.StatusCode() == http.StatusNotFound {
+			return fmt.Errorf("An identity provider with ID %q could not be found.", providerId)
+		}
+
+		return errors.New("Unexpected error fetching identity provider: " + string(resp.Body))
+	}
+
+	switch format {
+	case utils.OutputMetadata:
+		_, err := fmt.Println(*resp.JSON200.Saml.MetadataXml)
+		return err
+
+	case utils.OutputPretty:
+		return render.SingleMarkdown(api.Provider{
+			Id:        resp.JSON200.Id,
+			Saml:      resp.JSON200.Saml,
+			Domains:   resp.JSON200.Domains,
+			CreatedAt: resp.JSON200.CreatedAt,
+			UpdatedAt: resp.JSON200.UpdatedAt,
+		})
+
+	default:
+		return utils.EncodeOutput(format, os.Stdout, resp.JSON200)
+	}
+}
diff --git a/internal/sso/info/info.go b/internal/sso/info/info.go
new file mode 100644
index 00000000..406a5ef4
--- /dev/null
+++ b/internal/sso/info/info.go
@@ -0,0 +1,24 @@
+package info
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"github.com/supabase/cli/internal/sso/internal/render"
+	"github.com/supabase/cli/internal/utils"
+)
+
+func Run(ctx context.Context, ref string, format string) error {
+	switch format {
+	case utils.OutputPretty:
+		return render.InfoMarkdown(ref)
+
+	default:
+		return utils.EncodeOutput(format, os.Stdout, map[string]interface{}{
+			"acs_url":    fmt.Sprintf("https://%s.supabase.co/auth/v1/sso/saml/acs", ref),
+			"entity_id":  fmt.Sprintf("https://%s.supabase.co/auth/v1/sso/saml/metadata", ref),
+			"relaystate": fmt.Sprintf("https://%s.supabase.co", ref),
+		})
+	}
+}
diff --git a/internal/sso/internal/render/render.go b/internal/sso/internal/render/render.go
new file mode 100644
index 00000000..58d449f1
--- /dev/null
+++ b/internal/sso/internal/render/render.go
@@ -0,0 +1,208 @@
+package render
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/glamour"
+	"github.com/go-xmlfmt/xmlfmt"
+	"github.com/supabase/cli/pkg/api"
+)
+
+func formatProtocol(provider api.Provider) string {
+	protocol := "SAML 2.0"
+	if provider.Saml == nil || *provider.Saml == (api.SamlDescriptor{}) {
+		protocol = "unknown"
+	}
+
+	return protocol
+}
+
+func formatMetadataSource(provider api.Provider) string {
+	source := "FILE"
+	if provider.Saml != nil && provider.Saml.MetadataUrl != nil && *provider.Saml.MetadataUrl != "" {
+		source = *provider.Saml.MetadataUrl
+	}
+
+	return source
+}
+
+func formatTimestamp(timestamp *string) string {
+	if timestamp == nil {
+		return ""
+	}
+
+	t, err := time.Parse(time.RFC3339, *timestamp)
+
+	if err != nil {
+		return *timestamp
+	}
+
+	return t.UTC().Format("2006-01-02 15:04:05")
+}
+
+func formatDomains(provider api.Provider) string {
+	var domains []string
+
+	if provider.Domains != nil {
+		for _, domain := range *provider.Domains {
+			if domain.Domain != nil {
+				domains = append(domains, *domain.Domain)
+			}
+		}
+	}
+
+	domainsString := "-"
+	if len(domains) > 0 {
+		domainsString = strings.Join(domains, ", ")
+	}
+
+	return domainsString
+}
+
+func formatEntityID(provider api.Provider) string {
+	entityID := "-"
+	if provider.Saml != nil && provider.Saml.EntityId != "" {
+		entityID = provider.Saml.EntityId
+	}
+
+	return entityID
+}
+
+func ListMarkdown(providers []api.Provider) error {
+	markdownTable := []string{
+		"|TYPE|IDENTITY PROVIDER ID|DOMAINS|SAML 2.0 `EntityID`|CREATED AT (UTC)|UPDATED AT (UTC)|\n|-|-|-|-|-|-|\n",
+	}
+
+	for _, item := range providers {
+		markdownTable = append(markdownTable, fmt.Sprintf(
+			"|`%s`|`%s`|`%s`|`%s`|`%s`|`%s`|\n",
+			formatProtocol(item),
+			item.Id,
+			formatDomains(item),
+			formatEntityID(item),
+			formatTimestamp(item.CreatedAt),
+			formatTimestamp(item.UpdatedAt),
+		))
+	}
+
+	r, err := glamour.NewTermRenderer(
+		glamour.WithAutoStyle(),
+		glamour.WithWordWrap(-1),
+	)
+	if err != nil {
+		return err
+	}
+
+	out, err := r.Render(strings.Join(markdownTable, ""))
+	if err != nil {
+		return err
+	}
+
+	fmt.Print(out)
+	return nil
+}
+
+func SingleMarkdown(provider api.Provider) error {
+	markdownTable := []string{
+		"|PROPERTY|VALUE|",
+		"|-|-|",
+	}
+
+	markdownTable = append(markdownTable, fmt.Sprintf(
+		"|IDENTITY PROVIDER ID|`%s`|",
+		provider.Id,
+	))
+
+	markdownTable = append(markdownTable, fmt.Sprintf(
+		"|TYPE|`%s`|",
+		formatProtocol(provider),
+	))
+
+	markdownTable = append(markdownTable, fmt.Sprintf(
+		"|DOMAINS|`%s`|",
+		formatDomains(provider),
+	))
+
+	if provider.Saml != nil {
+		markdownTable = append(markdownTable, fmt.Sprintf(
+			"|SAML 2.0 METADATA|`%s`|",
+			formatMetadataSource(provider),
+		))
+
+		markdownTable = append(markdownTable, fmt.Sprintf(
+			"|SAML 2.0 `EntityID`|`%s`|",
+			formatEntityID(provider),
+		))
+	}
+
+	markdownTable = append(markdownTable, fmt.Sprintf(
+		"|CREATED AT (UTC)|`%s`|",
+		formatTimestamp(provider.CreatedAt),
+	))
+
+	markdownTable = append(markdownTable, fmt.Sprintf(
+		"|UPDATED AT (UTC)|`%s`|",
+		formatTimestamp(provider.CreatedAt),
+	))
+
+	if provider.Saml != nil && provider.Saml.MetadataXml != nil && *provider.Saml.MetadataXml != "" {
+		prettyXML := xmlfmt.FormatXML(*provider.Saml.MetadataXml, "  ", "  ")
+		markdownTable = append(markdownTable, "", "## SAML 2.0 Metadata XML", "```xml", prettyXML, "```")
+	}
+
+	r, err := glamour.NewTermRenderer(
+		glamour.WithAutoStyle(),
+		glamour.WithWordWrap(-1),
+	)
+	if err != nil {
+		return err
+	}
+
+	out, err := r.Render(strings.Join(markdownTable, "\n"))
+	if err != nil {
+		return err
+	}
+
+	fmt.Print(out)
+	return nil
+}
+
+func InfoMarkdown(ref string) error {
+	markdownTable := []string{
+		"|PROPERTY|VALUE|",
+		"|-|-|",
+	}
+
+	markdownTable = append(markdownTable, fmt.Sprintf(
+		"|Single sign-on URL (ACS URL) |`%s`|",
+		fmt.Sprintf("https://%s.supabase.co/auth/v1/sso/saml/acs", ref),
+	))
+
+	markdownTable = append(markdownTable, fmt.Sprintf(
+		"|Audience URI (SP Entity ID)|`%s`|",
+		fmt.Sprintf("https://%s.supabase.co/auth/v1/sso/saml/metadata", ref),
+	))
+
+	markdownTable = append(markdownTable, fmt.Sprintf(
+		"|Default Relaystate|`%s`|",
+		fmt.Sprintf("https://%s.supabase.co", ref),
+	))
+
+	r, err := glamour.NewTermRenderer(
+		glamour.WithAutoStyle(),
+		glamour.WithWordWrap(-1),
+	)
+	if err != nil {
+		return err
+	}
+
+	out, err := r.Render(strings.Join(markdownTable, "\n"))
+	if err != nil {
+		return err
+	}
+
+	fmt.Print(out)
+	return nil
+}
diff --git a/internal/sso/internal/saml/files.go b/internal/sso/internal/saml/files.go
new file mode 100644
index 00000000..c8a1c989
--- /dev/null
+++ b/internal/sso/internal/saml/files.go
@@ -0,0 +1,103 @@
+package saml
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"unicode/utf8"
+
+	"github.com/spf13/afero"
+	"github.com/supabase/cli/pkg/api"
+)
+
+var DefaultClient = http.DefaultClient
+
+func ReadMetadataFile(fsys afero.Fs, path string) (string, error) {
+	file, err := fsys.Open(path)
+	if err != nil {
+		return "", err
+	}
+
+	data, err := io.ReadAll(file)
+	if err != nil {
+		return "", err
+	}
+
+	if err := ValidateMetadata(data, path); err != nil {
+		return "", err
+	}
+
+	return string(data), nil
+}
+
+func ReadAttributeMappingFile(fsys afero.Fs, path string) (*api.AttributeMapping, error) {
+	file, err := fsys.Open(path)
+	if err != nil {
+		return nil, err
+	}
+
+	data, err := io.ReadAll(file)
+	if err != nil {
+		return nil, err
+	}
+
+	var mapping api.AttributeMapping
+
+	if err := json.Unmarshal(data, &mapping); err != nil {
+		return nil, err
+	}
+
+	return &mapping, nil
+}
+
+func ValidateMetadata(data []byte, source string) error {
+	if !utf8.Valid(data) {
+		return fmt.Errorf("SAML Metadata XML at %q is not UTF-8 encoded", source)
+	}
+
+	return nil
+}
+
+func ValidateMetadataURL(ctx context.Context, metadataURL string) error {
+	parsed, err := url.ParseRequestURI(metadataURL)
+	if err != nil {
+		return err
+	}
+
+	if strings.ToLower(parsed.Scheme) != "https:" {
+		return errors.New("Only HTTPS Metadata URLs are supported.")
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil)
+	if err != nil {
+		return err
+	}
+
+	req.Header.Add("Accept", "application/xml")
+
+	resp, err := DefaultClient.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("Received HTTP %v when fetching metatada at %q.", resp.Status, metadataURL)
+	}
+
+	data, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return err
+	}
+
+	if err := ValidateMetadata(data, metadataURL); err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/internal/sso/list/list.go b/internal/sso/list/list.go
new file mode 100644
index 00000000..02ad3fb8
--- /dev/null
+++ b/internal/sso/list/list.go
@@ -0,0 +1,36 @@
+package list
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"os"
+
+	"github.com/supabase/cli/internal/sso/internal/render"
+	"github.com/supabase/cli/internal/utils"
+)
+
+func Run(ctx context.Context, ref, format string) error {
+	resp, err := utils.GetSupabase().ListAllProvidersWithResponse(ctx, ref)
+	if err != nil {
+		return err
+	}
+
+	if resp.JSON200 == nil {
+		if resp.StatusCode() == http.StatusNotFound {
+			return errors.New("Looks like SAML 2.0 support is not enabled for this project. Please use the dashboard to enable it.")
+		}
+
+		return errors.New("unexpected error listing identity providers: " + string(resp.Body))
+	}
+
+	switch format {
+	case utils.OutputPretty:
+		return render.ListMarkdown(resp.JSON200.Items)
+
+	default:
+		return utils.EncodeOutput(format, os.Stdout, map[string]any{
+			"providers": resp.JSON200.Items,
+		})
+	}
+}
diff --git a/internal/sso/remove/remove.go b/internal/sso/remove/remove.go
new file mode 100644
index 00000000..03fea14b
--- /dev/null
+++ b/internal/sso/remove/remove.go
@@ -0,0 +1,42 @@
+package remove
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"os"
+
+	"github.com/supabase/cli/internal/sso/internal/render"
+	"github.com/supabase/cli/internal/utils"
+	"github.com/supabase/cli/pkg/api"
+)
+
+func Run(ctx context.Context, ref, providerId, format string) error {
+	resp, err := utils.GetSupabase().RemoveProviderByIdWithResponse(ctx, ref, providerId)
+	if err != nil {
+		return err
+	}
+
+	if resp.JSON200 == nil {
+		if resp.StatusCode() == http.StatusNotFound {
+			return fmt.Errorf("An identity provider with ID %q could not be found.", providerId)
+		}
+
+		return errors.New("Unexpected error removing identity provider: " + string(resp.Body))
+	}
+
+	switch format {
+	case utils.OutputPretty:
+		return render.SingleMarkdown(api.Provider{
+			Id:        resp.JSON200.Id,
+			Saml:      resp.JSON200.Saml,
+			Domains:   resp.JSON200.Domains,
+			CreatedAt: resp.JSON200.CreatedAt,
+			UpdatedAt: resp.JSON200.UpdatedAt,
+		})
+
+	default:
+		return utils.EncodeOutput(format, os.Stdout, resp.JSON200)
+	}
+}
diff --git a/internal/sso/update/update.go b/internal/sso/update/update.go
new file mode 100644
index 00000000..b16faed6
--- /dev/null
+++ b/internal/sso/update/update.go
@@ -0,0 +1,128 @@
+package update
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"os"
+
+	"github.com/spf13/afero"
+
+	"github.com/supabase/cli/internal/sso/internal/render"
+	"github.com/supabase/cli/internal/sso/internal/saml"
+	"github.com/supabase/cli/internal/utils"
+	"github.com/supabase/cli/pkg/api"
+)
+
+var Fs = afero.NewOsFs()
+
+type RunParams struct {
+	ProjectRef string
+	ProviderID string
+	Format     string
+
+	MetadataFile      string
+	MetadataURL       string
+	SkipURLValidation bool
+	AttributeMapping  string
+
+	Domains       []string
+	AddDomains    []string
+	RemoveDomains []string
+}
+
+func Run(ctx context.Context, params RunParams) error {
+	getResp, err := utils.GetSupabase().GetProviderByIdWithResponse(ctx, params.ProjectRef, params.ProviderID)
+	if err != nil {
+		return err
+	}
+
+	if getResp.JSON200 == nil {
+		if getResp.StatusCode() == http.StatusNotFound {
+			return fmt.Errorf("An identity provider with ID %q could not be found.", params.ProviderID)
+		}
+
+		return errors.New("unexpected error fetching identity provider: " + string(getResp.Body))
+	}
+
+	var body api.UpdateProviderByIdJSONRequestBody
+
+	if params.MetadataFile != "" {
+		data, err := saml.ReadMetadataFile(Fs, params.MetadataFile)
+		if err != nil {
+			return err
+		}
+
+		body.MetadataXml = &data
+	} else if params.MetadataURL != "" {
+		if !params.SkipURLValidation {
+			if err := saml.ValidateMetadataURL(ctx, params.MetadataURL); err != nil {
+				return fmt.Errorf("%w Use --skip-url-validation to suppress this error.", err)
+			}
+		}
+
+		body.MetadataUrl = &params.MetadataURL
+	}
+
+	if params.AttributeMapping != "" {
+		data, err := saml.ReadAttributeMappingFile(Fs, params.AttributeMapping)
+		if err != nil {
+			return err
+		}
+
+		body.AttributeMapping = data
+	}
+
+	if params.Domains != nil {
+		body.Domains = &params.Domains
+	} else if params.AddDomains != nil || params.RemoveDomains != nil {
+		domainsSet := make(map[string]bool)
+
+		if getResp.JSON200.Domains != nil {
+			for _, domain := range *getResp.JSON200.Domains {
+				if domain.Domain != nil {
+					domainsSet[*domain.Domain] = true
+				}
+			}
+		}
+
+		for _, rmDomain := range params.RemoveDomains {
+			delete(domainsSet, rmDomain)
+		}
+
+		for _, addDomain := range params.AddDomains {
+			domainsSet[addDomain] = true
+		}
+
+		var domains []string
+		for domain := range domainsSet {
+			domains = append(domains, domain)
+		}
+
+		body.Domains = &domains
+	}
+
+	putResp, err := utils.GetSupabase().UpdateProviderByIdWithResponse(ctx, params.ProjectRef, params.ProviderID, body)
+	if err != nil {
+		return err
+	}
+
+	if putResp.JSON200 == nil {
+		return errors.New("unexpected error fetching identity provider: " + string(putResp.Body))
+	}
+
+	switch params.Format {
+	case utils.OutputPretty:
+		return render.SingleMarkdown(api.Provider{
+			Id:        putResp.JSON200.Id,
+			Saml:      putResp.JSON200.Saml,
+			Domains:   putResp.JSON200.Domains,
+			CreatedAt: putResp.JSON200.CreatedAt,
+			UpdatedAt: putResp.JSON200.UpdatedAt,
+		})
+
+	default:
+		return utils.EncodeOutput(params.Format, os.Stdout, putResp.JSON200)
+	}
+}
diff --git a/internal/status/status.go b/internal/status/status.go
index ccf75f50..32f31858 100644
--- a/internal/status/status.go
+++ b/internal/status/status.go
@@ -2,27 +2,15 @@ package status
 
 import (
 	"context"
-	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 	"os"
 	"reflect"
 
-	"github.com/BurntSushi/toml"
 	"github.com/docker/docker/client"
-	"github.com/joho/godotenv"
 	"github.com/spf13/afero"
 	"github.com/supabase/cli/internal/utils"
-	"gopkg.in/yaml.v3"
-)
-
-const (
-	OutputEnv    = "env"
-	OutputJson   = "json"
-	OutputPretty = "pretty"
-	OutputToml   = "toml"
-	OutputYaml   = "yaml"
 )
 
 type CustomName struct {
@@ -91,7 +79,7 @@ func Run(ctx context.Context, names CustomName, format string, fsys afero.Fs) er
 	if len(stopped) > 0 {
 		fmt.Fprintln(os.Stderr, "Stopped services:", stopped)
 	}
-	if format == OutputPretty {
+	if format == utils.OutputPretty {
 		fmt.Fprintf(os.Stderr, "%s local development setup is running.\n\n", utils.Aqua("supabase"))
 		PrettyPrint(os.Stdout, stopped...)
 		return nil
@@ -145,22 +133,8 @@ func isPostgRESTHealthy(ctx context.Context) bool {
 
 func printStatus(names CustomName, format string, w io.Writer, exclude ...string) (err error) {
 	values := names.toValues(exclude...)
-	switch format {
-	case OutputEnv:
-		var out string
-		out, err = godotenv.Marshal(values)
-		fmt.Fprintln(w, out)
-	case OutputJson:
-		enc := json.NewEncoder(w)
-		err = enc.Encode(values)
-	case OutputYaml:
-		enc := yaml.NewEncoder(w)
-		err = enc.Encode(values)
-	case OutputToml:
-		enc := toml.NewEncoder(w)
-		err = enc.Encode(values)
-	}
-	return err
+
+	return utils.EncodeOutput(format, w, values)
 }
 
 func PrettyPrint(w io.Writer, exclude ...string) {
diff --git a/internal/utils/misc.go b/internal/utils/misc.go
index 2ae40da0..2281138e 100644
--- a/internal/utils/misc.go
+++ b/internal/utils/misc.go
@@ -100,6 +100,7 @@ var (
 
 	AccessTokenPattern = regexp.MustCompile(`^sbp_[a-f0-9]{40}$`)
 	ProjectRefPattern  = regexp.MustCompile(`^[a-z]{20}$`)
+	UUIDPattern        = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
 	ProjectHostPattern = regexp.MustCompile(`^(db\.)[a-z]{20}\.supabase\.(co|red)$`)
 	MigrateFilePattern = regexp.MustCompile(`^([0-9]+)_.*\.sql$`)
 	BranchNamePattern  = regexp.MustCompile(`[[:word:]-]+`)
diff --git a/internal/utils/output.go b/internal/utils/output.go
new file mode 100644
index 00000000..b36568af
--- /dev/null
+++ b/internal/utils/output.go
@@ -0,0 +1,64 @@
+package utils
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+
+	"github.com/BurntSushi/toml"
+	"github.com/joho/godotenv"
+	"gopkg.in/yaml.v2"
+)
+
+const (
+	OutputEnv    = "env"
+	OutputJson   = "json"
+	OutputPretty = "pretty"
+	OutputToml   = "toml"
+	OutputYaml   = "yaml"
+
+	// OutputMetadata is used with certain SSO commands only.
+	OutputMetadata = "metadata"
+)
+
+var (
+	OutputDefaultAllowed = []string{
+		OutputPretty,
+		OutputJson,
+		OutputToml,
+		OutputYaml,
+	}
+)
+
+func EncodeOutput(format string, w io.Writer, value any) error {
+	switch format {
+	case OutputEnv:
+		mapvalue, ok := value.(map[string]string)
+		if !ok {
+			return fmt.Errorf("value is not a map[string]string and can't be encoded as an environment file")
+		}
+
+		out, err := godotenv.Marshal(mapvalue)
+		if err != nil {
+			return err
+		}
+
+		_, err = fmt.Fprintln(w, out)
+		return err
+
+	case OutputJson:
+		enc := json.NewEncoder(w)
+		enc.SetIndent("", "  ")
+
+		return enc.Encode(value)
+
+	case OutputYaml:
+		return yaml.NewEncoder(w).Encode(value)
+
+	case OutputToml:
+		return toml.NewEncoder(w).Encode(value)
+
+	default:
+		return fmt.Errorf("Unsupported output encoding %q", format)
+	}
+}
diff --git a/pkg/api/types.gen.go b/pkg/api/types.gen.go
index 6a3ff9ee..c4a71202 100644
--- a/pkg/api/types.gen.go
+++ b/pkg/api/types.gen.go
@@ -3,6 +3,12 @@
 // Code generated by github.com/deepmap/oapi-codegen version v1.12.4 DO NOT EDIT.
 package api
 
+import (
+	"encoding/json"
+
+	"github.com/deepmap/oapi-codegen/pkg/runtime"
+)
+
 const (
 	ApiKeyHeaderScopes = "apiKeyHeader.Scopes"
 	ApiKeyParamScopes  = "apiKeyParam.Scopes"
@@ -156,11 +162,28 @@ type AttributeMapping struct {
 
 // AttributeValue defines model for AttributeValue.
 type AttributeValue struct {
-	Default *map[string]interface{} `json:"default,omitempty"`
+	Default *AttributeValue_Default `json:"default,omitempty"`
 	Name    *string                 `json:"name,omitempty"`
 	Names   *[]string               `json:"names,omitempty"`
 }
 
+// AttributeValueDefault0 defines model for .
+type AttributeValueDefault0 = map[string]interface{}
+
+// AttributeValueDefault1 defines model for .
+type AttributeValueDefault1 = float32
+
+// AttributeValueDefault2 defines model for .
+type AttributeValueDefault2 = string
+
+// AttributeValueDefault3 defines model for .
+type AttributeValueDefault3 = bool
+
+// AttributeValue_Default defines model for AttributeValue.Default.
+type AttributeValue_Default struct {
+	union json.RawMessage
+}
+
 // CreateFunctionBody defines model for CreateFunctionBody.
 type CreateFunctionBody struct {
 	Body      string `json:"body"`
@@ -403,6 +426,8 @@ type RemoveNetworkBanRequest struct {
 // SamlDescriptor defines model for SamlDescriptor.
 type SamlDescriptor struct {
 	AttributeMapping *AttributeMapping `json:"attribute_mapping,omitempty"`
+	EntityId         string            `json:"entity_id"`
+	Id               string            `json:"id"`
 	MetadataUrl      *string           `json:"metadata_url,omitempty"`
 	MetadataXml      *string           `json:"metadata_xml,omitempty"`
 }
@@ -586,3 +611,117 @@ type ActivateVanitySubdomainPleaseJSONRequestBody = VanitySubdomainBody
 
 // CheckVanitySubdomainAvailabilityJSONRequestBody defines body for CheckVanitySubdomainAvailability for application/json ContentType.
 type CheckVanitySubdomainAvailabilityJSONRequestBody = VanitySubdomainBody
+
+// AsAttributeValueDefault0 returns the union data inside the AttributeValue_Default as a AttributeValueDefault0
+func (t AttributeValue_Default) AsAttributeValueDefault0() (AttributeValueDefault0, error) {
+	var body AttributeValueDefault0
+	err := json.Unmarshal(t.union, &body)
+	return body, err
+}
+
+// FromAttributeValueDefault0 overwrites any union data inside the AttributeValue_Default as the provided AttributeValueDefault0
+func (t *AttributeValue_Default) FromAttributeValueDefault0(v AttributeValueDefault0) error {
+	b, err := json.Marshal(v)
+	t.union = b
+	return err
+}
+
+// MergeAttributeValueDefault0 performs a merge with any union data inside the AttributeValue_Default, using the provided AttributeValueDefault0
+func (t *AttributeValue_Default) MergeAttributeValueDefault0(v AttributeValueDefault0) error {
+	b, err := json.Marshal(v)
+	if err != nil {
+		return err
+	}
+
+	merged, err := runtime.JsonMerge(b, t.union)
+	t.union = merged
+	return err
+}
+
+// AsAttributeValueDefault1 returns the union data inside the AttributeValue_Default as a AttributeValueDefault1
+func (t AttributeValue_Default) AsAttributeValueDefault1() (AttributeValueDefault1, error) {
+	var body AttributeValueDefault1
+	err := json.Unmarshal(t.union, &body)
+	return body, err
+}
+
+// FromAttributeValueDefault1 overwrites any union data inside the AttributeValue_Default as the provided AttributeValueDefault1
+func (t *AttributeValue_Default) FromAttributeValueDefault1(v AttributeValueDefault1) error {
+	b, err := json.Marshal(v)
+	t.union = b
+	return err
+}
+
+// MergeAttributeValueDefault1 performs a merge with any union data inside the AttributeValue_Default, using the provided AttributeValueDefault1
+func (t *AttributeValue_Default) MergeAttributeValueDefault1(v AttributeValueDefault1) error {
+	b, err := json.Marshal(v)
+	if err != nil {
+		return err
+	}
+
+	merged, err := runtime.JsonMerge(b, t.union)
+	t.union = merged
+	return err
+}
+
+// AsAttributeValueDefault2 returns the union data inside the AttributeValue_Default as a AttributeValueDefault2
+func (t AttributeValue_Default) AsAttributeValueDefault2() (AttributeValueDefault2, error) {
+	var body AttributeValueDefault2
+	err := json.Unmarshal(t.union, &body)
+	return body, err
+}
+
+// FromAttributeValueDefault2 overwrites any union data inside the AttributeValue_Default as the provided AttributeValueDefault2
+func (t *AttributeValue_Default) FromAttributeValueDefault2(v AttributeValueDefault2) error {
+	b, err := json.Marshal(v)
+	t.union = b
+	return err
+}
+
+// MergeAttributeValueDefault2 performs a merge with any union data inside the AttributeValue_Default, using the provided AttributeValueDefault2
+func (t *AttributeValue_Default) MergeAttributeValueDefault2(v AttributeValueDefault2) error {
+	b, err := json.Marshal(v)
+	if err != nil {
+		return err
+	}
+
+	merged, err := runtime.JsonMerge(b, t.union)
+	t.union = merged
+	return err
+}
+
+// AsAttributeValueDefault3 returns the union data inside the AttributeValue_Default as a AttributeValueDefault3
+func (t AttributeValue_Default) AsAttributeValueDefault3() (AttributeValueDefault3, error) {
+	var body AttributeValueDefault3
+	err := json.Unmarshal(t.union, &body)
+	return body, err
+}
+
+// FromAttributeValueDefault3 overwrites any union data inside the AttributeValue_Default as the provided AttributeValueDefault3
+func (t *AttributeValue_Default) FromAttributeValueDefault3(v AttributeValueDefault3) error {
+	b, err := json.Marshal(v)
+	t.union = b
+	return err
+}
+
+// MergeAttributeValueDefault3 performs a merge with any union data inside the AttributeValue_Default, using the provided AttributeValueDefault3
+func (t *AttributeValue_Default) MergeAttributeValueDefault3(v AttributeValueDefault3) error {
+	b, err := json.Marshal(v)
+	if err != nil {
+		return err
+	}
+
+	merged, err := runtime.JsonMerge(b, t.union)
+	t.union = merged
+	return err
+}
+
+func (t AttributeValue_Default) MarshalJSON() ([]byte, error) {
+	b, err := t.union.MarshalJSON()
+	return b, err
+}
+
+func (t *AttributeValue_Default) UnmarshalJSON(b []byte) error {
+	err := t.union.UnmarshalJSON(b)
+	return err
+}


Test patch:
diff --git a/internal/sso/get/get_test.go b/internal/sso/get/get_test.go
new file mode 100644
index 00000000..6fdad706
--- /dev/null
+++ b/internal/sso/get/get_test.go
@@ -0,0 +1,95 @@
+package get
+
+import (
+	"context"
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/supabase/cli/internal/testing/apitest"
+	"github.com/supabase/cli/internal/utils"
+	"gopkg.in/h2non/gock.v1"
+)
+
+func TestSSOProvidersShowCommand(t *testing.T) {
+	t.Run("show provider", func(t *testing.T) {
+		// Setup valid access token
+		token := apitest.RandomAccessToken(t)
+		t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
+
+		// Flush pending mocks after test execution
+		defer gock.OffAll()
+
+		projectRef := "abcdefghijklmnopqrst"
+		providerId := "0b0d48f6-878b-4190-88d7-2ca33ed800bc"
+
+		gock.New(utils.DefaultApiHost).
+			Get("/v1/projects/" + projectRef + "/config/auth/sso/providers/" + providerId).
+			Reply(200).
+			JSON(map[string]any{
+				"id":         providerId,
+				"created_at": "2023-03-28T13:50:14.464Z",
+				"updated_at": "2023-03-28T13:50:14.464Z",
+				"saml": map[string]any{
+					"id":           "8682fcf4-4056-455c-bd93-f33295604929",
+					"metadata_url": "https://example.com",
+					"metadata_xml": "<?xml version=\"2.0\"?>",
+					"entity_id":    "https://example.com",
+					"attribute_mapping": map[string]any{
+						"keys": map[string]any{
+							"a": map[string]any{
+								"name": "xyz",
+								"names": []string{
+									"x",
+									"y",
+									"z",
+								},
+								"default": 3,
+							},
+						},
+					},
+					"created_at": "2023-03-28T13:50:14.464Z",
+					"updated_at": "2023-03-28T13:50:14.464Z",
+				},
+				"domains": []map[string]any{
+					{
+						"id":         "9484591c-a203-4500-bea7-d0aaa845e2f5",
+						"domain":     "example.com",
+						"created_at": "2023-03-28T13:50:14.464Z",
+						"updated_at": "2023-03-28T13:50:14.464Z",
+					},
+				},
+			})
+
+		// Run test
+		assert.NoError(t, Run(context.Background(), projectRef, providerId, utils.OutputPretty))
+		// Validate api
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+	})
+
+	t.Run("show provider that does not exist", func(t *testing.T) {
+		// Setup valid access token
+		token := apitest.RandomAccessToken(t)
+		t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
+
+		// Flush pending mocks after test execution
+		defer gock.OffAll()
+
+		projectRef := "abcdefghijklmnopqrst"
+		providerId := "0b0d48f6-878b-4190-88d7-2ca33ed800bc"
+
+		gock.New(utils.DefaultApiHost).
+			Get("/v1/projects/" + projectRef + "/config/auth/sso/providers/" + providerId).
+			Reply(404).
+			JSON(map[string]string{})
+
+		err := Run(context.Background(), projectRef, providerId, utils.OutputPretty)
+
+		// Run test
+		assert.Error(t, err)
+		assert.Equal(t, err.Error(), fmt.Sprintf("An identity provider with ID %q could not be found.", providerId))
+
+		// Validate api
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+	})
+}
diff --git a/internal/sso/internal/saml/files_test.go b/internal/sso/internal/saml/files_test.go
new file mode 100644
index 00000000..293f30cb
--- /dev/null
+++ b/internal/sso/internal/saml/files_test.go
@@ -0,0 +1,71 @@
+package saml
+
+import (
+	"context"
+	"os"
+	"testing"
+
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestReadAttributeMappingFile(t *testing.T) {
+	t.Run("open file that does not exist", func(t *testing.T) {
+		_, err := ReadAttributeMappingFile(afero.NewMemMapFs(), "/does-not-exist")
+		if !os.IsNotExist(err) {
+			t.Fatalf("unexpected error %v", err)
+		}
+	})
+
+	t.Run("open file that is not valid JSON", func(t *testing.T) {
+		fs := afero.NewMemMapFs()
+		assert.NoError(t, afero.WriteFile(fs, "/not-valid-json", []byte("not-valid-JSON"), 0755))
+
+		_, err := ReadAttributeMappingFile(fs, "/not-valid-json")
+		if err == nil {
+			t.Fatalf("expected error")
+		}
+	})
+
+	t.Run("open valid file", func(t *testing.T) {
+		fs := afero.NewMemMapFs()
+		assert.NoError(t, afero.WriteFile(fs, "/valid-json", []byte(`{"keys":{"abc":{"names":["x","y","z"],"default":2,"name":"k"}}}`), 0755))
+
+		_, err := ReadAttributeMappingFile(fs, "/valid-json")
+		if err != nil {
+			t.Fatalf("unexpected error %v", err)
+		}
+	})
+}
+
+func TestValidateMetadata(t *testing.T) {
+	t.Run("with invalid UTF-8", func(t *testing.T) {
+		err := ValidateMetadata([]byte{0xFF, 0xFF, 0xFF}, "/invalid-utf-8")
+		if err == nil || err.Error() != "SAML Metadata XML at \"/invalid-utf-8\" is not UTF-8 encoded" {
+			t.Fatalf("unexpected error %v", err)
+		}
+	})
+}
+
+func TestValidateMetadataURL(t *testing.T) {
+	t.Run("with relative URL", func(t *testing.T) {
+		err := ValidateMetadataURL(context.TODO(), "./relative-url")
+		if err == nil || err.Error() != "parse \"./relative-url\": invalid URI for request" {
+			t.Fatalf("unexpected error %v", err)
+		}
+	})
+
+	t.Run("with HTTP URL", func(t *testing.T) {
+		err := ValidateMetadataURL(context.TODO(), "http://example.com")
+		if err == nil || err.Error() != "Only HTTPS Metadata URLs are supported." {
+			t.Fatalf("unexpected error %v", err)
+		}
+	})
+
+	t.Run("with HTTP URL", func(t *testing.T) {
+		err := ValidateMetadataURL(context.TODO(), "http://example.com")
+		if err == nil || err.Error() != "Only HTTPS Metadata URLs are supported." {
+			t.Fatalf("unexpected error %v", err)
+		}
+	})
+}
diff --git a/internal/sso/list/list_test.go b/internal/sso/list/list_test.go
new file mode 100644
index 00000000..15cbad6a
--- /dev/null
+++ b/internal/sso/list/list_test.go
@@ -0,0 +1,96 @@
+package list
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/supabase/cli/internal/testing/apitest"
+	"github.com/supabase/cli/internal/utils"
+	"gopkg.in/h2non/gock.v1"
+)
+
+func TestSSOProvidersListCommand(t *testing.T) {
+	t.Run("lists all providers", func(t *testing.T) {
+		// Setup valid access token
+		token := apitest.RandomAccessToken(t)
+		t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
+
+		// Flush pending mocks after test execution
+		defer gock.OffAll()
+
+		projectRef := "abcdefghijklmnopqrst"
+
+		gock.New(utils.DefaultApiHost).
+			Get("/v1/projects/" + projectRef + "/config/auth/sso/providers").
+			Reply(200).
+			JSON(map[string]any{
+				"items": []map[string]any{
+					{
+						"id":         "0b0d48f6-878b-4190-88d7-2ca33ed800bc",
+						"created_at": "2023-03-28T13:50:14.464Z",
+						"updated_at": "2023-03-28T13:50:14.464Z",
+						"saml": map[string]any{
+							"id":           "8682fcf4-4056-455c-bd93-f33295604929",
+							"metadata_url": "https://example.com",
+							"metadata_xml": "<?xml version=\"2.0\"?>",
+							"entity_id":    "https://example.com",
+							"attribute_mapping": map[string]any{
+								"keys": map[string]any{
+									"a": map[string]any{
+										"name": "xyz",
+										"names": []string{
+											"x",
+											"y",
+											"z",
+										},
+										"default": 3,
+									},
+								},
+							},
+							"created_at": "2023-03-28T13:50:14.464Z",
+							"updated_at": "2023-03-28T13:50:14.464Z",
+						},
+						"domains": []map[string]any{
+							{
+								"id":         "9484591c-a203-4500-bea7-d0aaa845e2f5",
+								"domain":     "example.com",
+								"created_at": "2023-03-28T13:50:14.464Z",
+								"updated_at": "2023-03-28T13:50:14.464Z",
+							},
+						},
+					},
+				},
+			})
+
+		// Run test
+		assert.NoError(t, Run(context.Background(), projectRef, utils.OutputPretty))
+		// Validate api
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+	})
+
+	t.Run("list providers with disabled SAML", func(t *testing.T) {
+		// Setup valid access token
+		token := apitest.RandomAccessToken(t)
+		t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
+
+		// Flush pending mocks after test execution
+		defer gock.OffAll()
+
+		projectRef := "abcdefghijklmnopqrst"
+
+		gock.New(utils.DefaultApiHost).
+			Get("/v1/projects/" + projectRef + "/config/auth/sso/providers").
+			Reply(404).
+			JSON(map[string]string{})
+
+		err := Run(context.Background(), projectRef, utils.OutputPretty)
+
+		// Run test
+		assert.Error(t, err)
+		assert.Equal(t, err.Error(), "Looks like SAML 2.0 support is not enabled for this project. Please use the dashboard to enable it.")
+
+		// Validate api
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+	})
+}
diff --git a/internal/sso/remove/remove_test.go b/internal/sso/remove/remove_test.go
new file mode 100644
index 00000000..80195c49
--- /dev/null
+++ b/internal/sso/remove/remove_test.go
@@ -0,0 +1,95 @@
+package remove
+
+import (
+	"context"
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/supabase/cli/internal/testing/apitest"
+	"github.com/supabase/cli/internal/utils"
+	"gopkg.in/h2non/gock.v1"
+)
+
+func TestSSOProvidersRemoveCommand(t *testing.T) {
+	t.Run("remove provider", func(t *testing.T) {
+		// Setup valid access token
+		token := apitest.RandomAccessToken(t)
+		t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
+
+		// Flush pending mocks after test execution
+		defer gock.OffAll()
+
+		projectRef := "abcdefghijklmnopqrst"
+		providerId := "0b0d48f6-878b-4190-88d7-2ca33ed800bc"
+
+		gock.New(utils.DefaultApiHost).
+			Delete("/v1/projects/" + projectRef + "/config/auth/sso/providers/" + providerId).
+			Reply(200).
+			JSON(map[string]any{
+				"id":         providerId,
+				"created_at": "2023-03-28T13:50:14.464Z",
+				"updated_at": "2023-03-28T13:50:14.464Z",
+				"saml": map[string]any{
+					"id":           "8682fcf4-4056-455c-bd93-f33295604929",
+					"metadata_url": "https://example.com",
+					"metadata_xml": "<?xml version=\"2.0\"?>",
+					"entity_id":    "https://example.com",
+					"attribute_mapping": map[string]any{
+						"keys": map[string]any{
+							"a": map[string]any{
+								"name": "xyz",
+								"names": []string{
+									"x",
+									"y",
+									"z",
+								},
+								"default": 3,
+							},
+						},
+					},
+					"created_at": "2023-03-28T13:50:14.464Z",
+					"updated_at": "2023-03-28T13:50:14.464Z",
+				},
+				"domains": []map[string]any{
+					{
+						"id":         "9484591c-a203-4500-bea7-d0aaa845e2f5",
+						"domain":     "example.com",
+						"created_at": "2023-03-28T13:50:14.464Z",
+						"updated_at": "2023-03-28T13:50:14.464Z",
+					},
+				},
+			})
+
+		// Run test
+		assert.NoError(t, Run(context.Background(), projectRef, providerId, utils.OutputPretty))
+		// Validate api
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+	})
+
+	t.Run("remove provider that does not exist", func(t *testing.T) {
+		// Setup valid access token
+		token := apitest.RandomAccessToken(t)
+		t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
+
+		// Flush pending mocks after test execution
+		defer gock.OffAll()
+
+		projectRef := "abcdefghijklmnopqrst"
+		providerId := "0b0d48f6-878b-4190-88d7-2ca33ed800bc"
+
+		gock.New(utils.DefaultApiHost).
+			Delete("/v1/projects/" + projectRef + "/config/auth/sso/providers/" + providerId).
+			Reply(404).
+			JSON(map[string]string{})
+
+		err := Run(context.Background(), projectRef, providerId, utils.OutputPretty)
+
+		// Run test
+		assert.Error(t, err)
+		assert.Equal(t, err.Error(), fmt.Sprintf("An identity provider with ID %q could not be found.", providerId))
+
+		// Validate api
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+	})
+}
diff --git a/internal/sso/update/update_test.go b/internal/sso/update/update_test.go
new file mode 100644
index 00000000..d0b4ec1a
--- /dev/null
+++ b/internal/sso/update/update_test.go
@@ -0,0 +1,195 @@
+package update
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/supabase/cli/internal/testing/apitest"
+	"github.com/supabase/cli/internal/utils"
+	"github.com/supabase/cli/pkg/api"
+	"gopkg.in/h2non/gock.v1"
+)
+
+func response(providerId string, domains []string) map[string]any {
+	resp := map[string]any{
+		"id":         providerId,
+		"created_at": "2023-03-28T13:50:14.464Z",
+		"updated_at": "2023-03-28T13:50:14.464Z",
+		"saml": map[string]any{
+			"id":           "8682fcf4-4056-455c-bd93-f33295604929",
+			"metadata_url": "https://example.com",
+			"metadata_xml": "<?xml version=\"2.0\"?>",
+			"entity_id":    "https://example.com",
+			"attribute_mapping": map[string]any{
+				"keys": map[string]any{
+					"a": map[string]any{
+						"name": "xyz",
+						"names": []string{
+							"x",
+							"y",
+							"z",
+						},
+						"default": 3,
+					},
+				},
+			},
+			"created_at": "2023-03-28T13:50:14.464Z",
+			"updated_at": "2023-03-28T13:50:14.464Z",
+		},
+		"domains": []map[string]any{},
+	}
+
+	for _, domain := range domains {
+		respDomains := resp["domains"].([]map[string]any)
+		resp["domains"] = append(respDomains, map[string]any{
+			"id":         "9484591c-a203-4500-bea7-d0aaa845e2f5",
+			"domain":     domain,
+			"created_at": "2023-03-28T13:50:14.464Z",
+			"updated_at": "2023-03-28T13:50:14.464Z",
+		})
+	}
+
+	return resp
+}
+
+func TestSSOProvidersUpdateCommand(t *testing.T) {
+	t.Run("update provider", func(t *testing.T) {
+		// Setup valid access token
+		token := apitest.RandomAccessToken(t)
+		t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
+
+		// Flush pending mocks after test execution
+		defer gock.OffAll()
+
+		projectRef := "abcdefghijklmnopqrst"
+		providerId := "0b0d48f6-878b-4190-88d7-2ca33ed800bc"
+
+		gock.New(utils.DefaultApiHost).
+			Get("/v1/projects/" + projectRef + "/config/auth/sso/providers/" + providerId).
+			Reply(200).
+			JSON(response(providerId, []string{"example.com"}))
+
+		gock.New(utils.DefaultApiHost).
+			Put("/v1/projects/" + projectRef + "/config/auth/sso/providers/" + providerId).
+			Reply(200).
+			JSON(response(providerId, []string{"new-domain.com"}))
+
+		observed := 0
+		gock.Observe(func(r *http.Request, mock gock.Mock) {
+			if r.Method != http.MethodPut {
+				return
+			}
+			observed += 1
+
+			var body api.UpdateProviderByIdJSONRequestBody
+			assert.NoError(t, json.NewDecoder(r.Body).Decode(&body))
+
+			assert.NotNil(t, body.Domains)
+			assert.Equal(t, 1, len(*body.Domains))
+			assert.Equal(t, "new-domain.com", (*body.Domains)[0])
+		})
+
+		// Run test
+		assert.NoError(t, Run(context.Background(), RunParams{
+			ProjectRef: projectRef,
+			ProviderID: providerId,
+			Format:     utils.OutputPretty,
+
+			Domains: []string{
+				"new-domain.com",
+			},
+		}))
+		// Validate api
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+		assert.Equal(t, 1, observed)
+	})
+
+	t.Run("update provider with --add-domains and --remove-domains", func(t *testing.T) {
+		// Setup valid access token
+		token := apitest.RandomAccessToken(t)
+		t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
+
+		// Flush pending mocks after test execution
+		defer gock.OffAll()
+
+		projectRef := "abcdefghijklmnopqrst"
+		providerId := "0b0d48f6-878b-4190-88d7-2ca33ed800bc"
+
+		gock.New(utils.DefaultApiHost).
+			Get("/v1/projects/" + projectRef + "/config/auth/sso/providers/" + providerId).
+			Reply(200).
+			JSON(response(providerId, []string{"example.com"}))
+
+		gock.New(utils.DefaultApiHost).
+			Put("/v1/projects/" + projectRef + "/config/auth/sso/providers/" + providerId).
+			Reply(200).
+			JSON(response(providerId, []string{"new-domain.com"}))
+
+		observed := 0
+		gock.Observe(func(r *http.Request, mock gock.Mock) {
+			if r.Method != http.MethodPut {
+				return
+			}
+			observed += 1
+
+			var body api.UpdateProviderByIdJSONRequestBody
+			assert.NoError(t, json.NewDecoder(r.Body).Decode(&body))
+
+			assert.NotNil(t, body.Domains)
+			assert.Equal(t, 1, len(*body.Domains))
+			assert.Equal(t, "new-domain.com", (*body.Domains)[0])
+		})
+
+		// Run test
+		assert.NoError(t, Run(context.Background(), RunParams{
+			ProjectRef: projectRef,
+			ProviderID: providerId,
+			Format:     utils.OutputPretty,
+
+			AddDomains: []string{
+				"new-domain.com",
+			},
+			RemoveDomains: []string{
+				"example.com",
+			},
+		}))
+		// Validate api
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+		assert.Equal(t, 1, observed)
+
+	})
+
+	t.Run("update provider that does not exist", func(t *testing.T) {
+		// Setup valid access token
+		token := apitest.RandomAccessToken(t)
+		t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
+
+		// Flush pending mocks after test execution
+		defer gock.OffAll()
+
+		projectRef := "abcdefghijklmnopqrst"
+		providerId := "0b0d48f6-878b-4190-88d7-2ca33ed800bc"
+
+		gock.New(utils.DefaultApiHost).
+			Get("/v1/projects/" + projectRef + "/config/auth/sso/providers/" + providerId).
+			Reply(404).
+			JSON(map[string]string{})
+
+		err := Run(context.Background(), RunParams{
+			ProjectRef: projectRef,
+			ProviderID: providerId,
+			Format:     utils.OutputPretty,
+		})
+
+		// Run test
+		assert.Error(t, err)
+		assert.Equal(t, err.Error(), fmt.Sprintf("An identity provider with ID %q could not be found.", providerId))
+
+		// Validate api
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+	})
+}
diff --git a/internal/status/status_test.go b/internal/status/status_test.go
index b2f07d75..50c8a181 100644
--- a/internal/status/status_test.go
+++ b/internal/status/status_test.go
@@ -48,13 +48,13 @@ func TestStatusCommand(t *testing.T) {
 				}})
 		}
 		// Run test
-		assert.NoError(t, Run(context.Background(), CustomName{}, OutputPretty, fsys))
+		assert.NoError(t, Run(context.Background(), CustomName{}, utils.OutputPretty, fsys))
 		// Check error
 		assert.Empty(t, apitest.ListUnmatchedRequests())
 	})
 
 	t.Run("throws error on missing config", func(t *testing.T) {
-		err := Run(context.Background(), CustomName{}, OutputPretty, afero.NewMemMapFs())
+		err := Run(context.Background(), CustomName{}, utils.OutputPretty, afero.NewMemMapFs())
 		assert.ErrorIs(t, err, os.ErrNotExist)
 	})
 
@@ -63,7 +63,7 @@ func TestStatusCommand(t *testing.T) {
 		fsys := afero.NewMemMapFs()
 		require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("malformed"), 0644))
 		// Run test
-		err := Run(context.Background(), CustomName{}, OutputPretty, fsys)
+		err := Run(context.Background(), CustomName{}, utils.OutputPretty, fsys)
 		// Check error
 		assert.ErrorContains(t, err, "toml: line 0: unexpected EOF; expected key separator '='")
 	})
@@ -79,7 +79,7 @@ func TestStatusCommand(t *testing.T) {
 			Get("/v" + utils.Docker.ClientVersion() + "/containers/supabase_db_").
 			ReplyError(errors.New("network error"))
 		// Run test
-		err := Run(context.Background(), CustomName{}, OutputPretty, fsys)
+		err := Run(context.Background(), CustomName{}, utils.OutputPretty, fsys)
 		// Check error
 		assert.ErrorContains(t, err, "network error")
 		assert.Empty(t, apitest.ListUnmatchedRequests())
@@ -153,7 +153,7 @@ func TestPrintStatus(t *testing.T) {
 	t.Run("outputs env var", func(t *testing.T) {
 		// Run test
 		var stdout bytes.Buffer
-		assert.NoError(t, printStatus(CustomName{DbURL: "DB_URL"}, OutputEnv, &stdout, exclude...))
+		assert.NoError(t, printStatus(CustomName{DbURL: "DB_URL"}, utils.OutputEnv, &stdout, exclude...))
 		// Check error
 		assert.Equal(t, "DB_URL=\"postgresql://postgres:postgres@localhost:0/postgres\"\n", stdout.String())
 	})
@@ -161,15 +161,15 @@ func TestPrintStatus(t *testing.T) {
 	t.Run("outputs json object", func(t *testing.T) {
 		// Run test
 		var stdout bytes.Buffer
-		assert.NoError(t, printStatus(CustomName{DbURL: "DB_URL"}, OutputJson, &stdout, exclude...))
+		assert.NoError(t, printStatus(CustomName{DbURL: "DB_URL"}, utils.OutputJson, &stdout, exclude...))
 		// Check error
-		assert.Equal(t, "{\"DB_URL\":\"postgresql://postgres:postgres@localhost:0/postgres\"}\n", stdout.String())
+		assert.Equal(t, "{\n  \"DB_URL\": \"postgresql://postgres:postgres@localhost:0/postgres\"\n}\n", stdout.String())
 	})
 
 	t.Run("outputs yaml properties", func(t *testing.T) {
 		// Run test
 		var stdout bytes.Buffer
-		assert.NoError(t, printStatus(CustomName{DbURL: "DB_URL"}, OutputYaml, &stdout, exclude...))
+		assert.NoError(t, printStatus(CustomName{DbURL: "DB_URL"}, utils.OutputYaml, &stdout, exclude...))
 		// Check error
 		assert.Equal(t, "DB_URL: postgresql://postgres:postgres@localhost:0/postgres\n", stdout.String())
 	})
@@ -177,7 +177,7 @@ func TestPrintStatus(t *testing.T) {
 	t.Run("outputs toml fields", func(t *testing.T) {
 		// Run test
 		var stdout bytes.Buffer
-		assert.NoError(t, printStatus(CustomName{DbURL: "DB_URL"}, OutputToml, &stdout, exclude...))
+		assert.NoError(t, printStatus(CustomName{DbURL: "DB_URL"}, utils.OutputToml, &stdout, exclude...))
 		// Check error
 		assert.Equal(t, "DB_URL = \"postgresql://postgres:postgres@localhost:0/postgres\"\n", stdout.String())
 	})