Repository: supabase/gotrue
Pull request #768 (2022-10-24 09:12:29)

fix: add improved HTTP metrics
Metrics were not collected if only the metrics exporter was enabled. This is now rectified.

It also counts HTTP status codes per route, so that misbehaving routes can be easily identified. Example:

```prometheus
# HELP http_status_codes Number of returned HTTP status codes
# TYPE http_status_codes counter
http_status_codes{code="400",gotrue_version="",http_route="/authorize"} 1
http_status_codes{code="405",gotrue_version="",http_route="/*"} 1
```

Finally, it adds a gauge metric to count all running versions:

```prometheus
# HELP gotrue_running Whether GoTrue is running (always 1)
# TYPE gotrue_running gauge
gotrue_running{gotrue_version=""} 1
```


Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ff3464a3..b65b5b5b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -40,7 +40,7 @@ jobs:
 
       - uses: actions/setup-go@v3
         with:
-          go-version: "^1.18.0" # The Go version to download (if necessary) and use.
+          go-version: "^1.19.0" # The Go version to download (if necessary) and use.
 
       - run: make deps
       - run: make all
diff --git a/Dockerfile b/Dockerfile
index 5ad8e930..0ddc9166 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.18-alpine as build
+FROM golang:1.19-alpine as build
 ENV GO111MODULE=on
 ENV CGO_ENABLED=0
 ENV GOOS=linux
diff --git a/Dockerfile.dev b/Dockerfile.dev
index 650cff18..079a40bb 100644
--- a/Dockerfile.dev
+++ b/Dockerfile.dev
@@ -1,4 +1,4 @@
-FROM golang:1.18-alpine
+FROM golang:1.19-alpine
 ENV GO111MODULE=on
 ENV CGO_ENABLED=0
 ENV GOOS=linux
diff --git a/api/api.go b/api/api.go
index 74d3b721..0e71c1c4 100644
--- a/api/api.go
+++ b/api/api.go
@@ -64,14 +64,18 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 	r := newRouter()
 	r.Use(addRequestID(globalConfig))
 
+	// request tracing should be added only when tracing or metrics is
+	// enabled
 	if globalConfig.Tracing.Enabled {
 		switch globalConfig.Tracing.Exporter {
-		case conf.OpenTelemetryTracing:
-			r.UseBypass(observability.RequestTracing())
-
 		case conf.OpenTracing:
 			r.UseBypass(opentracer)
+
+		default:
+			r.UseBypass(observability.RequestTracing())
 		}
+	} else if globalConfig.Metrics.Enabled {
+		r.UseBypass(observability.RequestTracing())
 	}
 
 	r.UseBypass(xffmw.Handler)
diff --git a/conf/configuration.go b/conf/configuration.go
index fef348f9..7937bc39 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -9,6 +9,7 @@ import (
 	"github.com/gobwas/glob"
 	"github.com/joho/godotenv"
 	"github.com/kelseyhightower/envconfig"
+	"github.com/sirupsen/logrus"
 )
 
 const defaultMinPasswordLength int = 6
@@ -112,6 +113,7 @@ type GlobalConfiguration struct {
 		Domain   string `json:"domain"`
 		Duration int    `json:"duration"`
 	} `json:"cookies"`
+	SAML SAMLConfiguration `json:"saml"`
 }
 
 // EmailContentConfiguration holds the configuration for emails, both subjects and template URLs.
@@ -273,6 +275,16 @@ func LoadGlobal(filename string) (*GlobalConfiguration, error) {
 		return nil, err
 	}
 
+	if config.SAML.Enabled {
+		logrus.Warn("WARNING: SAML is an incomplete feature and should not be enabled for production use!")
+
+		if err := config.SAML.PopulateFields(config.API.ExternalURL); err != nil {
+			return nil, err
+		}
+	} else {
+		config.SAML.PrivateKey = ""
+	}
+
 	return config, nil
 }
 
@@ -380,6 +392,7 @@ func (c *GlobalConfiguration) Validate() error {
 		&c.Tracing,
 		&c.Metrics,
 		&c.SMTP,
+		&c.SAML,
 	}
 
 	for _, validatable := range validatables {
diff --git a/conf/saml.go b/conf/saml.go
new file mode 100644
index 00000000..50dd8836
--- /dev/null
+++ b/conf/saml.go
@@ -0,0 +1,109 @@
+package conf
+
+import (
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"math/big"
+	"net/url"
+	"time"
+)
+
+// SAMLConfiguration holds configuration for native SAML support.
+type SAMLConfiguration struct {
+	Enabled                  bool          `json:"enabled"`
+	PrivateKey               string        `json:"-" split_words:"true"`
+	RelayStateValidityPeriod time.Duration `json:"relay_state_validity_period" split_words:"true"`
+
+	RSAPrivateKey *rsa.PrivateKey   `json:"-"`
+	RSAPublicKey  *rsa.PublicKey    `json:"-"`
+	Certificate   *x509.Certificate `json:"-"`
+}
+
+func (c *SAMLConfiguration) Validate() error {
+	if c.Enabled {
+		bytes, err := base64.StdEncoding.DecodeString(c.PrivateKey)
+		if err != nil {
+			return errors.New("SAML private key not in standard Base64 format")
+		}
+
+		privateKey, err := x509.ParsePKCS1PrivateKey(bytes)
+		if err != nil {
+			return errors.New("SAML private key not in PKCS#1 format")
+		}
+
+		err = privateKey.Validate()
+		if err != nil {
+			return errors.New("SAML private key is not valid")
+		}
+
+		if privateKey.E != 0x10001 {
+			return errors.New("SAML private key should use the 65537 (0x10001) RSA public exponent")
+		}
+
+		if privateKey.N.BitLen() < 2048 {
+			return errors.New("SAML private key must be at least RSA 2048")
+		}
+
+		if c.RelayStateValidityPeriod < 0 {
+			return errors.New("SAML RelayState validity period should be a positive duration")
+		}
+	}
+
+	return nil
+}
+
+// PopulateFields fills the configuration details based off the provided
+// parameters.
+func (c *SAMLConfiguration) PopulateFields(externalURL string) error {
+	// errors are intentionally ignored since they should have been handled
+	// within #Validate()
+	bytes, _ := base64.StdEncoding.DecodeString(c.PrivateKey)
+	privateKey, _ := x509.ParsePKCS1PrivateKey(bytes)
+
+	c.RSAPrivateKey = privateKey
+	c.RSAPublicKey = privateKey.Public().(*rsa.PublicKey)
+
+	parsedURL, err := url.ParseRequestURI(externalURL)
+	if err != nil {
+		return fmt.Errorf("saml: unable to parse external URL for SAML, check API_EXTERNAL_URL: %w", err)
+	}
+
+	// SAML does not care much about the contents of the certificate, it
+	// only uses it as a vessel for the public key; therefore we set these
+	// fixed values.
+	// Please avoid modifying or adding new values to this template as they
+	// will change the exposed SAML certificate, requiring users of
+	// GoTrue to re-establish a connection between their Identity Provider
+	// and their running GoTrue instances.
+	certTemplate := &x509.Certificate{
+		SerialNumber: big.NewInt(0),
+		IsCA:         false,
+		DNSNames: []string{
+			"_samlsp." + parsedURL.Host,
+		},
+		KeyUsage:  x509.KeyUsageDigitalSignature,
+		NotBefore: time.UnixMilli(0).UTC(),
+		NotAfter:  time.UnixMilli(0).UTC().AddDate(200, 0, 0),
+	}
+
+	certDer, err := x509.CreateCertificate(nil, certTemplate, certTemplate, c.RSAPublicKey, c.RSAPrivateKey)
+	if err != nil {
+		return err
+	}
+
+	cert, err := x509.ParseCertificate(certDer)
+	if err != nil {
+		return err
+	}
+
+	c.Certificate = cert
+
+	if c.RelayStateValidityPeriod == 0 {
+		c.RelayStateValidityPeriod = 2 * time.Minute
+	}
+
+	return nil
+}
diff --git a/docs/doc.go b/docs/doc.go
index 52e4302c..5d5a1483 100644
--- a/docs/doc.go
+++ b/docs/doc.go
@@ -2,19 +2,19 @@
 //
 // Documentation of the gotrue API.
 //
-//     Schemes: http, https
-//     BasePath: /
-//     Version: 1.0.0
-//     Host: localhost:9999
+//	Schemes: http, https
+//	BasePath: /
+//	Version: 1.0.0
+//	Host: localhost:9999
 //
-//     SecurityDefinitions:
-//     bearer:
-//       type: apiKey
-//       name: Authentication
-//       in: header
+//	SecurityDefinitions:
+//	bearer:
+//	  type: apiKey
+//	  name: Authentication
+//	  in: header
 //
-//     Produces:
-//     - application/json
+//	Produces:
+//	- application/json
 //
 // swagger:meta
 package docs
diff --git a/go.mod b/go.mod
index 5495192a..eae28f73 100644
--- a/go.mod
+++ b/go.mod
@@ -140,4 +140,4 @@ require (
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
 
-go 1.18
+go 1.19
diff --git a/migrations/20221021082433_add_saml.up.sql b/migrations/20221021082433_add_saml.up.sql
new file mode 100644
index 00000000..68b794b3
--- /dev/null
+++ b/migrations/20221021082433_add_saml.up.sql
@@ -0,0 +1,90 @@
+-- Multi-instance mode (see auth.instances) table intentionally not supported and ignored.
+
+create table if not exists auth.sso_providers (
+	id uuid not null,
+	resource_id text null,
+	created_at timestamptz null,
+	updated_at timestamptz null,
+	primary key (id),
+	constraint "resource_id not empty" check (resource_id = null or char_length(resource_id) > 0)
+);
+
+comment on table auth.sso_providers is 'Auth: Manages SSO identity provider information; see saml_providers for SAML.';
+comment on column auth.sso_providers.resource_id is 'Auth: Uniquely identifies a SSO provider according to a user-chosen resource ID (case insensitive), useful in infrastructure as code.';
+
+create unique index if not exists sso_providers_resource_id_idx on auth.sso_providers (lower(resource_id));
+
+create table if not exists auth.sso_domains (
+	id uuid not null,
+	sso_provider_id uuid not null,
+	domain text not null,
+	created_at timestamptz null,
+	updated_at timestamptz null,
+	primary key (id),
+	foreign key (sso_provider_id) references auth.sso_providers (id) on delete cascade,
+	constraint "domain not empty" check (char_length(domain) > 0)
+);
+
+create index if not exists sso_domains_sso_provider_id_idx on auth.sso_domains (sso_provider_id);
+create unique index if not exists sso_domains_domain_idx on auth.sso_domains (lower(domain));
+
+comment on table auth.sso_domains is 'Auth: Manages SSO email address domain mapping to an SSO Identity Provider.';
+
+create table if not exists auth.saml_providers (
+	id uuid not null,
+	sso_provider_id uuid not null,
+	entity_id text not null unique,
+	metadata_xml text not null,
+	metadata_url text null,
+	attribute_mapping jsonb null,
+	created_at timestamptz null,
+	updated_at timestamptz null,
+	primary key (id),
+	foreign key (sso_provider_id) references auth.sso_providers (id) on delete cascade,
+	constraint "metadata_xml not empty" check (char_length(metadata_xml) > 0),
+	constraint "metadata_url not empty" check (metadata_url = null or char_length(metadata_url) > 0),
+	constraint "entity_id not empty" check (char_length(entity_id) > 0)
+);
+
+create index if not exists saml_providers_sso_provider_id_idx on auth.saml_providers (sso_provider_id);
+
+comment on table auth.saml_providers is 'Auth: Manages SAML Identity Provider connections.';
+
+create table if not exists auth.saml_relay_states (
+	id uuid not null,
+	sso_provider_id uuid not null,
+	request_id text not null,
+	for_email text null,
+	redirect_to text null,
+	from_ip_address inet null,
+	created_at timestamptz null,
+	updated_at timestamptz null,
+	primary key (id),
+	foreign key (sso_provider_id) references auth.sso_providers (id) on delete cascade,
+	constraint "request_id not empty" check(char_length(request_id) > 0)
+);
+
+create index if not exists saml_relay_states_sso_provider_id_idx on auth.saml_relay_states (sso_provider_id);
+create index if not exists saml_relay_states_for_email_idx on auth.saml_relay_states (for_email);
+
+comment on table auth.saml_relay_states is 'Auth: Contains SAML Relay State information for each Service Provider initiated login.';
+
+create table if not exists auth.sso_sessions (
+	id uuid not null,
+	session_id uuid not null,
+	sso_provider_id uuid null,
+	not_before timestamptz null,
+	not_after timestamptz null,
+	idp_initiated boolean default false,
+	created_at timestamptz null,
+	updated_at timestamptz null,
+	primary key (id),
+	foreign key (session_id) references auth.sessions (id) on delete cascade,
+	foreign key (sso_provider_id) references auth.sso_providers (id) on delete cascade
+);
+
+create index if not exists sso_sessions_session_id_idx on auth.sso_sessions (session_id);
+create index if not exists sso_sessions_sso_provider_id_idx on auth.sso_sessions (sso_provider_id);
+
+comment on table auth.sso_sessions is 'Auth: A session initiated by an SSO Identity Provider';
+
diff --git a/observability/metrics.go b/observability/metrics.go
index 9ab18d0e..c3079713 100644
--- a/observability/metrics.go
+++ b/observability/metrics.go
@@ -17,6 +17,7 @@ import (
 	"go.opentelemetry.io/otel/exporters/prometheus"
 	"go.opentelemetry.io/otel/metric"
 	metricglobal "go.opentelemetry.io/otel/metric/global"
+	metricinstrument "go.opentelemetry.io/otel/metric/instrument"
 	basicmetriccontroller "go.opentelemetry.io/otel/sdk/metric/controller/basic"
 	exportmetricaggregation "go.opentelemetry.io/otel/sdk/metric/export/aggregation"
 	basicmetricprocessor "go.opentelemetry.io/otel/sdk/metric/processor/basic"
@@ -179,6 +180,27 @@ func ConfigureMetrics(ctx context.Context, mc *conf.MetricsConfig) error {
 		} else {
 			logrus.Info("Go runtime metrics collection started")
 		}
+
+		meter := metricglobal.Meter("gotrue")
+		running, err := meter.AsyncInt64().Gauge(
+			"gotrue_running",
+			metricinstrument.WithDescription("Whether GoTrue is running (always 1)"),
+		)
+		if err != nil {
+			logrus.WithError(err).Error("unable to get gotrue.gotrue_running gague metric")
+			return
+		}
+
+		if err := meter.RegisterCallback(
+			[]metricinstrument.Asynchronous{
+				running,
+			},
+			func(ctx context.Context) {
+				running.Observe(ctx, 1)
+			},
+		); err != nil {
+			logrus.WithError(err).Error("unable to register gotrue.running gague metric")
+		}
 	})
 
 	return err
diff --git a/observability/request-tracing.go b/observability/request-tracing.go
index b63a4077..b5f3ee1d 100644
--- a/observability/request-tracing.go
+++ b/observability/request-tracing.go
@@ -1,12 +1,15 @@
 package observability
 
 import (
+	"context"
 	"net/http"
 
 	"github.com/go-chi/chi"
 	"github.com/sirupsen/logrus"
 	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
 	"go.opentelemetry.io/otel/attribute"
+	metricglobal "go.opentelemetry.io/otel/metric/global"
+	metricinstrument "go.opentelemetry.io/otel/metric/instrument"
 	semconv "go.opentelemetry.io/otel/semconv/v1.12.0"
 	"go.opentelemetry.io/otel/trace"
 )
@@ -57,16 +60,88 @@ func traceChiRouteURLParamsSafely(r *http.Request) {
 	}
 }
 
+type interceptingResponseWriter struct {
+	writer http.ResponseWriter
+
+	statusCode int
+}
+
+func (w *interceptingResponseWriter) WriteHeader(statusCode int) {
+	w.statusCode = statusCode
+
+	w.writer.WriteHeader(statusCode)
+}
+
+func (w *interceptingResponseWriter) Write(data []byte) (int, error) {
+	return w.writer.Write(data)
+}
+
+func (w *interceptingResponseWriter) Header() http.Header {
+	return w.writer.Header()
+}
+
+type metricCounter interface {
+	Add(ctx context.Context, incr int64, attrs ...attribute.KeyValue)
+}
+
+// countStatusCodesSafely counts the number of HTTP status codes per route that
+// occurred while GoTrue was running. If it is not able to identify the route
+// via chi.RouteContext(ctx).RoutePattern() it counts with a noroute attribute.
+func countStatusCodesSafely(w *interceptingResponseWriter, r *http.Request, counter metricCounter) {
+	if counter == nil {
+		return
+	}
+
+	defer func() {
+		if rec := recover(); rec != nil {
+			logrus.WithField("error", rec).Error("unable to count status codes safely, metrics may be off")
+
+			counter.Add(
+				r.Context(),
+				1,
+				attribute.Bool("noroute", true),
+				attribute.Int("code", w.statusCode),
+			)
+		}
+	}()
+
+	ctx := r.Context()
+
+	routeContext := chi.RouteContext(ctx)
+	routePattern := semconv.HTTPRouteKey.String(routeContext.RoutePattern())
+
+	counter.Add(
+		ctx,
+		1,
+		attribute.Int("code", w.statusCode),
+		routePattern,
+	)
+}
+
 // RequestTracing returns an HTTP handler that traces all HTTP requests coming
 // in. Supports Chi routers, so this should be one of the first middlewares on
 // the router.
 func RequestTracing() func(http.Handler) http.Handler {
+	meter := metricglobal.Meter("gotrue")
+	statusCodes, err := meter.SyncInt64().Counter(
+		"http_status_codes",
+		metricinstrument.WithDescription("Number of returned HTTP status codes"),
+	)
+	if err != nil {
+		logrus.WithError(err).Error("unable to get gotrue.http_status_codes counter metric")
+	}
+
 	return func(next http.Handler) http.Handler {
 		fn := func(w http.ResponseWriter, r *http.Request) {
+			writer := interceptingResponseWriter{
+				writer: w,
+			}
+
 			defer traceChiRoutesSafely(r)
 			defer traceChiRouteURLParamsSafely(r)
+			defer countStatusCodesSafely(&writer, r, statusCodes)
 
-			next.ServeHTTP(w, r)
+			next.ServeHTTP(&writer, r)
 		}
 
 		return otelhttp.NewHandler(http.HandlerFunc(fn), "api")


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 2387c390..2c5401f6 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -14,7 +14,7 @@ jobs:
   test:
     strategy:
       matrix:
-        go-version: [1.18.x]
+        go-version: [1.19.x]
     runs-on: ubuntu-20.04
     services:
       postgres:
diff --git a/conf/saml_test.go b/conf/saml_test.go
new file mode 100644
index 00000000..e8de37e1
--- /dev/null
+++ b/conf/saml_test.go
@@ -0,0 +1,101 @@
+package conf
+
+import (
+	tst "testing"
+
+	"encoding/base64"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestSAMLConfigurationValidate(t *tst.T) {
+	invalidExamples := []*SAMLConfiguration{
+		{
+			Enabled:    true,
+			PrivateKey: "",
+		},
+		{
+			Enabled:    true,
+			PrivateKey: "InvalidBase64!",
+		},
+		{
+			Enabled:    true,
+			PrivateKey: base64.StdEncoding.EncodeToString([]byte("not PKCS#1")),
+		},
+		{
+			// RSA 1024 key
+			Enabled:    true,
+			PrivateKey: "MIICXQIBAAKBgQDFa3SgzWZpcoONv3Iq3FxNieks2u2TmykxxxeggI9aNpHpuCzwGQO8wqXGVvFNlkE3GSPcz7rklzfyj577Z47lfWdBP1OAefralA3tS2mafqpZ32JwDynX4as+xauLVdP4iOR96b3L2eOb6rDpr4wBJuNqO533xsjcbNPINEDkSwIDAQABAoGASggBtEtSHDjVHFKufWQlOO5+glOWw8Nrrz75nTaYizvre7mVIHRA8ogLolT4KCAwVHkY+bTsYMxULqGs/JnY+40suHECYQ2u76PTQlvJnhJANGtCxuV4lSK6B8QBJhjGExsnAOwMMKz0p5kVftx2GA+/Rz2De7DR9keNECjcAAECQQDtr5cdkEdnIffvi782843EvX/g8615AKZeUYVUl0gVXujjpIVZXDtytPHINvIW1Z2mOm2rlJukwiKYYJ8IjsxlAkEA1KGbJ9EI6AOUcnpy7FYdGkbINTDngCqVOoaddlHS+1SaofpYXZPueXXIqIG3viksxmq/Q0IY6+JRkGo/RpGq7wJARD+BAqok9oYYbR4RX7P7ZxyKlYsiqnX3T2nVAP8XYZuI/6SD7a7AGyW9ryGnzcq0o8BvMS9QqbRcvqgvwgNOyQJBAL2ZVMaOSIjKGGZz9WHz74Nstj1n3CWW0vYa7vGASMc/S5s/pefbbvvzIPfQo0z3XiuXJ/ELUTmU1vIVK1L7tRUCQQCsuE7xckZ8H/523jdWWy9zZVP1z4c5dVLDR5RY+YQNForgb6kkSv4Gzn/FRUOxqn2MEWJLla31D4EuS+XKuwZR",
+		},
+		{
+			// RSA 2048 with 0x11 as public exponent
+			Enabled:    true,
+			PrivateKey: "MIIEowIBAAKCAQEAyMvTanPoiorCpIQCl70qXF34FIPOkKaInr1vw+3/0nik5CDUo761E02uTrK4/8JXr5NLGmy/fQmagNsBOdKewciRB3xxs+sPNncptG4rpCBjxSJdVl+mYZaw2kdvFY7TvNTlr7qG1Q0kV/3lBgpMlyM9OqBrjuG0UUzB5hlg08KLNflkQAkoJGWNVWULi2VceP3I3QsH9uNUQkgaM9Z6rl0BaRAkobHTTvquAqqj1AlNmSh24rrIbV4hYcNnesIpG4+LDd8XfpOwTp+jUl8akF6xcRBJjiPDJGN9ety29DcCxjo2i0b+TWYU+Pex08uOeOdulsgecbIVxLUEgRHcFQIBEQKCAQBefgkjCV5fUFuYtpfO75t2ws8Ytn9TITE7pHDUrDwmz1ynlvqnaM2uuyTZvYQ8HzhSn6rfQjv+mxuH7pcqRP9qQEQ/whdjuekKkm36jjKnlsWJ8g3OSyEe3YBmuDRGYVSVGOSO7l2Rb5ih4OQ/E+fOpyvfWoz38b5EYFs/GwBjpgJG+9cdCLYKOax8WDifWkjHdrogAlE8do/QF6RZoSvhAbRkpuxYActmKU8rIORrq8dLidSjBG2aoRH+RCN4ONZ3R4iHbYF2zWfqDFdSIX64kChaOZVhtTyTnF7/1v4VF3UwByEs8hTSckFH2jW6T7RZoatpgsv5zx/roRPDBWNRAoGBAPGphQwX9GF56XVmqD9rQMD9a0FuGCNGgiFkt2OUTFKmr4rTNVE8uJqEXjQwbybTnF2iJ1ApL1zNHg0cpnpMt7ZpcWG4Bu2UsXlwBL/ZwY4Spk6tHNdTsg/wuoWRSIGNanNS6CI5EUA4cxGNUt0G+dF4LaMHZuIAU7avs+kwDMzHAoGBANS1nS8KYkPUwYlmgVPNhMDTtjvq7fgP5UFDXnlhE6rJidc/+B0p9WiRhLGWlZebn+h2fELfIgK3qc4IzCHOkar0pic2D3bNbboNQKnqFl81hg0EORTK0JJ5/K4J61l5+rZtQu3Ss1HVwDiy9SKg6F3CQj9PK0r+hjtAStFSmZxDAoGBAMcEEzciyUE3OLsJP0NJRGKylJA8jFlJH99D4lIBqEQQzMyt76xQH45O5Cr6teO9U5hna6ttNhAwcxnbW+w/LeGEAwUuI9K2sEXjx60NrnUATLlDRO2QOElc1ddolhBWV6pERrLFlbxquR2DcWq6c2E1yzr3CW7TF8OfwVagCoqFAoGBAK8sJxeuMs5y+bxyiJ9d9NsItDFYD0TBy9tkqCe5W32W6fyPCJB86Df/XjflbCKAKVYHOSgDDPMt1yIlXNCL/326arbhOeld4eSDYm3P1jBKMijWTSAujaXN3yXqDRyCkjvhgmmAV3CR6Zga5/5mZQHrRZ2MfgGGUG0HxSTanJ7NAoGBAOhZBGtFsBdtEawvCh4Z8NaMC2nU+Ru9hEsZSy6rZQrPvja9aBUk5QUdh04TYtu8PzQ1EghZy71rtwDAvxXWJ1mWcZn0kD06tZKudmZpMVXCp3SFah6DDUCFSmQ2U60yh6XOzpS2+Z97Ngi02UFph8sSQA6Dl/lmaf4bfQHCYc5Z",
+		},
+	}
+
+	for i, example := range invalidExamples {
+		err := example.Validate()
+		require.Error(t, err, "Invalid example %d was regarded as valid", i)
+	}
+
+	validExamples := []*SAMLConfiguration{
+		{
+			Enabled: false,
+		},
+		{
+			// RSA 2048
+			Enabled:    true,
+			PrivateKey: "MIIEowIBAAKCAQEAsBuxTUWFrfy0qYXaqNSeVWcJOd6TQ4+4b/3N4p/58r1d/kMU+K+BGR+tF0GKHGYngTF6puvNDff2wgW3dp3LUSMjxOhC3sK0uL90vd+IR6v1EDDGLyQNo6EjP/x5Gp/PcL2s6hZb8iLBEq4FksPnEhWqf9Nsmgf1YPJV4AvaaWe3oBFo9zJobSs3etTVitc3qEH2DpgYFtrCKhMWv5qoZtZTyZRE3LU3rvInDgYw6HDGF1G4y4Fvah6VpRmTdyMR81r1tCLmGvk61QJp7i4HteazQ6Raqh2EZ1sH/UfEp8mrwYRaRdgLDQ/Q6/YlO8NTQwzp6YwwAybhMBnOrABLCQIDAQABAoIBADqobq0DPByQsIhKmmNjtn1RvYP1++0kANXknuAeUv2kT5tyMpkGtCRvJZM6dEszR3NDzMuufPVrI1jK2Kn8sw0KfE6I4kUaa2Gh+7uGqfjdcNn8tPZctuJKuNgGOzxAALNXqjGqUuPa6Z5UMm0JLX0blFfRTzoa7oNlFG9040H6CRjJQQGfYyPS8xeo+RUR009sK/222E5jz6ThIiCrOU/ZGm5Ws9y3AAIASqJd9QPy7qxKoFZ1qKZ/cDaf1txCKq9VBXH6ypZoU1dQibhyLCIJ3tYapBtV4p8V12oHhITXb6Vbo1P9bQSVz+2rQ0nJkjdXX/N4aHE01ecbu8MpMxUCgYEA5P4ZCAdpkTaOSJi7GyL4AcZ5MN26eifFnRO/tbmw07f6vi//vdqzC9T7kxmZ8e1OvhX5OMGNb3nsXm78WgS2EVLTkaTInG6XhlOeYj9BHAQZDBr7rcAxrVQxVgaGDiZpYun++kXw+39iq3gxuYuC9mM0AQze3SjTRIM9WWXJSqMCgYEAxODfXcWMk2P/WfjE3u+8fhjc3cvqyWSyThEZC9YzpN59dL73SE7BRkMDyZO19fFvVO9mKsRfsTio0ceC5XQOO6hUxAm4gAEvMpeapQgXTxIxF5FAQ0vGmBMxT+xg7lX8HTTJX/UCttKo3BdIJQeTf8bKVzJCoLFh8Rcv5qI6umMCgYAEuj44DTcfuVmcpBKQz9sA5mEQIjO8W9/Xi1XU4Z2F8XFqxcDo4X/6yY3cDpZACV8ry3ZWtqA94e2AUZhCH4DGwMf/ZMCDgkD8k/NcIeQtOORvfIsfni0oX+mY1g+kcSSR1zTdY95CwvF9isC0DO5KOegT8XkUZchezLrSgqhyMwKBgQCvS0mWRH6V/UMu6MDhfrNl0t1U3mt+RZo8yBx03ZO+CBvMBvxF9VlBJgoJQOuSwBVQmpdtHMvXD4vAvNNfWaYSmB5hLgaIcoWDlliq+DlIvfnX8gw13xJD9VLCxsTHcOe5WXazaYOxJIAU9uXVkplR+73NRYLtcQKzluGfiHKh4QKBgFpPtOqcAbkMsV+1qPYvvvX7E4+l52Odb4tbxGBYV8tzCqMRETqMPVxFWwsj+EQ8lyAu15rCRH7DKHVK5zL6JvIZEjt0tptKqSL2o3ovS6y3DmD6t+YpvjKME7a+vunOoJWe9pWl3wZmodfyZMpAdDLvDGhPR7Jlhun41tbMMaQF",
+		},
+		{
+			// RSA 3072
+			Enabled:    true,
+			PrivateKey: "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",
+		},
+		{
+			// RSA 4096
+			Enabled:    true,
+			PrivateKey: "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",
+		},
+	}
+
+	for i, example := range validExamples {
+		err := example.Validate()
+		require.NoError(t, err, "Valid example %d was regarded as invalid", i)
+	}
+}
+
+func TestSAMLConfigurationPopulateFields(t *tst.T) {
+	c := &SAMLConfiguration{
+		Enabled:    true,
+		PrivateKey: "MIIEowIBAAKCAQEAt7dS8iM5MsQ+1mVkNpoaUnL8BCdxSrSx8jsSnvqN/GIJ4ipqbdrTgLpFVklVTqfaa5CykGVEV577l6AWkpkm2p7SvSkCQglmyAMMjY9glmztytAnfBpm+cQ6ZVTHC4XKlUG1aJigEuXPcZUU3FiBHWEuV2huYy2bLOtIY1v9N0i2v61QCdG+SM/Yb5t86KzApRl7VyHqquge6vvRuchfF0msv/2LW32hwxg3Gt4zkAF0SJqCCcfAPZ9pQwmbdUhoX16dRFU98nyIvuR8LH/wONZe/YyywFFHDEwkFa4XEzjCEm+AD+xvK7eEu55w21xB8JKMLEBy8uRuI3bIEG4pawIDAQABAoIBADw4IT4xgYw8e4R3U7P6K2qfOjB6ZU5hkHqgFmh6JJR35ll2IdDEi9OEOzofa5EOwC/GDGH8b7xw5nM7DGsdPHko2lca3BydTE1/glvchYKJTiDOvkKVvO9d/O4+Lch/IHpwQXB5pu7K2YaXoXDgqeHhevk3yAdGabj9norDGmtGIeU/x1hialKbw6L080CdbxpjeAsM/w+G/VtwvyOKYFBYxBflRW+sS8UeclVqKRAvaXKd1JGleWzH3hFZyFI54x5LyyjPI1JyVXRjNbf8xcS6eRaN849grL1+wBxEs/lQFn4JLhAcNi912iJ3lhxvkNleXZw7B7JAM8x4wUbK7zECgYEA6SYmu3YH8XuLUfT8MMCp+ETjPkNMOJGQmTXOkW6zuXP3J8iCPIxtuz09cGIro+yJU23yPUzOVCDZMmnMWBmkoTKAFoFL9TX0Eyqn/t1MD77i3NdkMp16yI5fwOO6yX1bZgLiG00W2E5/IGgNfTtEafU/mre95JBnTgxS3sAvz8UCgYEAybjfBVt+1X0vSVAGKYHI9wtzoSx3dIGE8G5LIchPTdNDZ0ke0QCRffhyCGKy6bPos0P2z5nLgWSePBPZQowpwZiQVXdWE05ID641E2zGULdYL1yVHDt6tVTpSzTAy89BiS1G8HvgpQyaBTmvmF11Fyd/YbrDxEIHN+qQdDkM928CgYEA4lJ4ksz21QF6sqpADQtZc3lbplspqFgVp8RFq4Nsz3+00lefpSskcff2phuGBXBdtjEqTzs5pwzkCj4NcRAjcZ9WG4KTu4sOTXTA83TamwZPrtUfnMqmH/2lEdd+wI0BpjryRlJE9ODuIwUe4wwfU0QQ5B2tJizPO0JXR4gEYYkCgYBzqidm4QGm1DLq7JG79wkObmiMv/x2t1VMr1ExO7QNQdfiP1EGMjc6bdyk5kMEMf5527yHaP4BYXpBpHfs6oV+1kXcW6LlSvuS0iboznQgECDmd0WgfJJtqxRh5QuvUVWYnHeSqNU0jjc6S8tdqCjdb+5gUUCzJdERxNOzcIr4zQKBgAqcBQwlWy0PdlZ06JhJUYlwX1pOU8mWPz9LIF0wrSm9LEtAl37zZJaD3uscvk/fCixAGHOktkDGVO7aUYIAlX9iD49huGkeRTn9tz7Wanw6am04Xj0y7H1oPPV7k5nJ4s9AOWq/gkZEhrRIis2anAczsx1YHSjq/M05+AbuRzvs",
+	}
+
+	err := c.PopulateFields("https://projectref.supabase.co")
+	require.NoError(t, err)
+
+	require.NotNil(t, c.RSAPrivateKey)
+	require.NotNil(t, c.RSAPublicKey)
+	require.NotNil(t, c.Certificate)
+}
+
+func TestSAMLConfigurationDeterministicCertificate(t *tst.T) {
+	a := &SAMLConfiguration{
+		Enabled:    true,
+		PrivateKey: "MIIEowIBAAKCAQEAt7dS8iM5MsQ+1mVkNpoaUnL8BCdxSrSx8jsSnvqN/GIJ4ipqbdrTgLpFVklVTqfaa5CykGVEV577l6AWkpkm2p7SvSkCQglmyAMMjY9glmztytAnfBpm+cQ6ZVTHC4XKlUG1aJigEuXPcZUU3FiBHWEuV2huYy2bLOtIY1v9N0i2v61QCdG+SM/Yb5t86KzApRl7VyHqquge6vvRuchfF0msv/2LW32hwxg3Gt4zkAF0SJqCCcfAPZ9pQwmbdUhoX16dRFU98nyIvuR8LH/wONZe/YyywFFHDEwkFa4XEzjCEm+AD+xvK7eEu55w21xB8JKMLEBy8uRuI3bIEG4pawIDAQABAoIBADw4IT4xgYw8e4R3U7P6K2qfOjB6ZU5hkHqgFmh6JJR35ll2IdDEi9OEOzofa5EOwC/GDGH8b7xw5nM7DGsdPHko2lca3BydTE1/glvchYKJTiDOvkKVvO9d/O4+Lch/IHpwQXB5pu7K2YaXoXDgqeHhevk3yAdGabj9norDGmtGIeU/x1hialKbw6L080CdbxpjeAsM/w+G/VtwvyOKYFBYxBflRW+sS8UeclVqKRAvaXKd1JGleWzH3hFZyFI54x5LyyjPI1JyVXRjNbf8xcS6eRaN849grL1+wBxEs/lQFn4JLhAcNi912iJ3lhxvkNleXZw7B7JAM8x4wUbK7zECgYEA6SYmu3YH8XuLUfT8MMCp+ETjPkNMOJGQmTXOkW6zuXP3J8iCPIxtuz09cGIro+yJU23yPUzOVCDZMmnMWBmkoTKAFoFL9TX0Eyqn/t1MD77i3NdkMp16yI5fwOO6yX1bZgLiG00W2E5/IGgNfTtEafU/mre95JBnTgxS3sAvz8UCgYEAybjfBVt+1X0vSVAGKYHI9wtzoSx3dIGE8G5LIchPTdNDZ0ke0QCRffhyCGKy6bPos0P2z5nLgWSePBPZQowpwZiQVXdWE05ID641E2zGULdYL1yVHDt6tVTpSzTAy89BiS1G8HvgpQyaBTmvmF11Fyd/YbrDxEIHN+qQdDkM928CgYEA4lJ4ksz21QF6sqpADQtZc3lbplspqFgVp8RFq4Nsz3+00lefpSskcff2phuGBXBdtjEqTzs5pwzkCj4NcRAjcZ9WG4KTu4sOTXTA83TamwZPrtUfnMqmH/2lEdd+wI0BpjryRlJE9ODuIwUe4wwfU0QQ5B2tJizPO0JXR4gEYYkCgYBzqidm4QGm1DLq7JG79wkObmiMv/x2t1VMr1ExO7QNQdfiP1EGMjc6bdyk5kMEMf5527yHaP4BYXpBpHfs6oV+1kXcW6LlSvuS0iboznQgECDmd0WgfJJtqxRh5QuvUVWYnHeSqNU0jjc6S8tdqCjdb+5gUUCzJdERxNOzcIr4zQKBgAqcBQwlWy0PdlZ06JhJUYlwX1pOU8mWPz9LIF0wrSm9LEtAl37zZJaD3uscvk/fCixAGHOktkDGVO7aUYIAlX9iD49huGkeRTn9tz7Wanw6am04Xj0y7H1oPPV7k5nJ4s9AOWq/gkZEhrRIis2anAczsx1YHSjq/M05+AbuRzvs",
+	}
+
+	b := &SAMLConfiguration{
+		Enabled:    a.Enabled,
+		PrivateKey: a.PrivateKey,
+	}
+
+	err := a.PopulateFields("https://projectref.supabase.co")
+	require.NoError(t, err)
+
+	err = b.PopulateFields("https://projectref.supabase.co")
+	require.NoError(t, err)
+
+	require.Equal(t, a.Certificate.Raw, b.Certificate.Raw, "Certificate generation should be deterministic")
+}