Repository: supabase/gotrue
Pull request #750 (2022-10-14 18:54:48)

fix: properly escape `redirectTo` URL for magic links
If the user passes a proper URL with query params or a fragment in the `redirectTo` parameter on sign-in with magic links, then the query parameters and the fragment will "break" the URL because they are not query escaped. Since some users have already noticed this and started query escaping the `redirectTo` parameter, to keep backward compatibility with them, the URL will only be escaped if it contains any of the unescaped characters that should not appear within a query parameter (i.e. `&=#`).

kangmingtay: @hf i think we need to do this for the other email links too (e.g invite, recovery, signup and email change)

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/api/magic_link.go b/api/magic_link.go
index 1bdce600..41975faa 100644
--- a/api/magic_link.go
+++ b/api/magic_link.go
@@ -16,7 +16,7 @@ import (
 // MagicLinkParams holds the parameters for a magic link request
 type MagicLinkParams struct {
 	Email string                 `json:"email"`
-	Data  map[string]interface{} `json:"metadata"`
+	Data  map[string]interface{} `json:"data"`
 }
 
 // MagicLink sends a recovery email
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/mailer/template.go b/mailer/template.go
index 14311501..bae1e9ff 100644
--- a/mailer/template.go
+++ b/mailer/template.go
@@ -2,6 +2,8 @@ package mailer
 
 import (
 	"fmt"
+	"net/url"
+	"strings"
 
 	"github.com/badoux/checkmail"
 	"github.com/netlify/gotrue/conf"
@@ -19,6 +21,22 @@ type TemplateMailer struct {
 	Mailer  MailClient
 }
 
+func encodeRedirectParam(referrerURL string) string {
+	redirectParam := ""
+	if len(referrerURL) > 0 {
+		if strings.ContainsAny(referrerURL, "&=#") {
+			// if the string contains &, = or # it has not been URL
+			// encoded by the caller, which means it should be URL
+			// encoded by us otherwise, it should be taken as-is
+			referrerURL = url.QueryEscape(referrerURL)
+		}
+
+		redirectParam = "&redirect_to=" + referrerURL
+	}
+
+	return redirectParam
+}
+
 const defaultInviteMail = `<h2>You have been invited</h2>
 
 <p>You have been invited to create a user on {{ .SiteURL }}. Follow this link to accept the invite:</p>
@@ -62,10 +80,7 @@ func (m TemplateMailer) ValidateEmail(email string) error {
 
 // InviteMail sends a invite mail to a new user
 func (m *TemplateMailer) InviteMail(user *models.User, otp, referrerURL string) error {
-	redirectParam := ""
-	if len(referrerURL) > 0 {
-		redirectParam = "&redirect_to=" + referrerURL
-	}
+	redirectParam := encodeRedirectParam(referrerURL)
 
 	url, err := getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.Invite, "token="+user.ConfirmationToken+"&type=invite"+redirectParam)
 	if err != nil {
@@ -90,10 +105,8 @@ func (m *TemplateMailer) InviteMail(user *models.User, otp, referrerURL string)
 
 // ConfirmationMail sends a signup confirmation mail to a new user
 func (m *TemplateMailer) ConfirmationMail(user *models.User, otp, referrerURL string) error {
-	redirectParam := ""
-	if len(referrerURL) > 0 {
-		redirectParam = "&redirect_to=" + referrerURL
-	}
+	redirectParam := encodeRedirectParam(referrerURL)
+
 	url, err := getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.Confirmation, "token="+user.ConfirmationToken+"&type=signup"+redirectParam)
 	if err != nil {
 		return err
@@ -163,10 +176,8 @@ func (m *TemplateMailer) EmailChangeMail(user *models.User, otpNew, otpCurrent,
 		})
 	}
 
-	redirectParam := ""
-	if len(referrerURL) > 0 {
-		redirectParam = "&redirect_to=" + referrerURL
-	}
+	redirectParam := encodeRedirectParam(referrerURL)
+
 	errors := make(chan error)
 	for _, email := range emails {
 		url, err := getSiteURL(
@@ -209,10 +220,7 @@ func (m *TemplateMailer) EmailChangeMail(user *models.User, otpNew, otpCurrent,
 
 // RecoveryMail sends a password recovery mail
 func (m *TemplateMailer) RecoveryMail(user *models.User, otp, referrerURL string) error {
-	redirectParam := ""
-	if len(referrerURL) > 0 {
-		redirectParam = "&redirect_to=" + referrerURL
-	}
+	redirectParam := encodeRedirectParam(referrerURL)
 
 	url, err := getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.Recovery, "token="+user.RecoveryToken+"&type=recovery"+redirectParam)
 	if err != nil {
@@ -237,10 +245,7 @@ func (m *TemplateMailer) RecoveryMail(user *models.User, otp, referrerURL string
 
 // MagicLinkMail sends a login link mail
 func (m *TemplateMailer) MagicLinkMail(user *models.User, otp, referrerURL string) error {
-	redirectParam := ""
-	if len(referrerURL) > 0 {
-		redirectParam = "&redirect_to=" + referrerURL
-	}
+	redirectParam := encodeRedirectParam(referrerURL)
 
 	url, err := getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.Recovery, "token="+user.RecoveryToken+"&type=magiclink"+redirectParam)
 	if err != nil {
@@ -278,10 +283,7 @@ func (m TemplateMailer) Send(user *models.User, subject, body string, data map[s
 func (m TemplateMailer) GetEmailActionLink(user *models.User, actionType, referrerURL string) (string, error) {
 	var err error
 
-	redirectParam := ""
-	if len(referrerURL) > 0 {
-		redirectParam = "&redirect_to=" + referrerURL
-	}
+	redirectParam := encodeRedirectParam(referrerURL)
 
 	var url string
 	switch actionType {
diff --git a/migrations/20221020193600_add_sessions_user_id_index.up.sql b/migrations/20221020193600_add_sessions_user_id_index.up.sql
new file mode 100644
index 00000000..f5ba0425
--- /dev/null
+++ b/migrations/20221020193600_add_sessions_user_id_index.up.sql
@@ -0,0 +1,2 @@
+create index if not exists sessions_user_id_idx on {{ index .Options "Namespace" }}.sessions (user_id);
+
diff --git a/migrations/20221021073300_add_refresh_tokens_session_id_revoked_index.up.sql b/migrations/20221021073300_add_refresh_tokens_session_id_revoked_index.up.sql
new file mode 100644
index 00000000..0c47d4a7
--- /dev/null
+++ b/migrations/20221021073300_add_refresh_tokens_session_id_revoked_index.up.sql
@@ -0,0 +1,1 @@
+create index if not exists refresh_tokens_session_id_revoked_idx on {{ index .Options "Namespace" }}.refresh_tokens (session_id, revoked);
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/models/factor.go b/models/factor.go
index f21f2a94..8169b867 100644
--- a/models/factor.go
+++ b/models/factor.go
@@ -4,6 +4,7 @@ import (
 	"database/sql"
 	"time"
 
+	"github.com/gobuffalo/pop/v5"
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/storage"
 	"github.com/pkg/errors"
@@ -34,7 +35,7 @@ func (authMethod AuthenticationMethod) String() string {
 	case OTP:
 		return "otp"
 	case TOTPSignIn:
-		return "mfa/totp"
+		return "totp"
 	}
 	return ""
 }
@@ -146,6 +147,15 @@ func (f *Factor) UpdateFactorType(tx *storage.Connection, factorType string) err
 }
 
 func (f *Factor) DowngradeSessionsToAAL1(tx *storage.Connection) error {
+	sessions, err := FindSessionsByFactorID(tx, f.ID)
+	if err != nil {
+		return err
+	}
+	for _, session := range sessions {
+		if err := tx.RawQuery("DELETE FROM "+(&pop.Model{Value: AMRClaim{}}).TableName()+" WHERE session_id = ? AND authentication_method = ?", session.ID, f.FactorType).Exec(); err != nil {
+			return err
+		}
+	}
 	return updateFactorAssociatedSessions(tx, f.UserID, f.ID, AAL1.String())
 
 }
diff --git a/models/refresh_token.go b/models/refresh_token.go
index 497aade1..98ba3885 100644
--- a/models/refresh_token.go
+++ b/models/refresh_token.go
@@ -72,7 +72,7 @@ func RevokeTokenFamily(tx *storage.Connection, token *RefreshToken) error {
 	var err error
 	tablename := (&pop.Model{Value: RefreshToken{}}).TableName()
 	if token.SessionId != nil {
-		err = tx.RawQuery(`update `+tablename+` set revoked = true where session_id = ?;`, token.SessionId).Exec()
+		err = tx.RawQuery(`update `+tablename+` set revoked = true where session_id = ? and revoked = false;`, token.SessionId).Exec()
 	} else {
 		err = tx.RawQuery(`
 		with recursive token_family as (
diff --git a/models/sessions.go b/models/sessions.go
index c13382f4..2320b91e 100644
--- a/models/sessions.go
+++ b/models/sessions.go
@@ -129,8 +129,16 @@ func FindSessionByUserID(tx *storage.Connection, userId uuid.UUID) (*Session, er
 	return session, nil
 }
 
+func FindSessionsByFactorID(tx *storage.Connection, factorID uuid.UUID) ([]*Session, error) {
+	sessions := []*Session{}
+	if err := tx.Q().Where("factor_id = ?", factorID).All(&sessions); err != nil {
+		return nil, err
+	}
+	return sessions, nil
+}
+
 func updateFactorAssociatedSessions(tx *storage.Connection, userID, factorID uuid.UUID, aal string) error {
-	return tx.RawQuery("UPDATE "+(&pop.Model{Value: Session{}}).TableName()+" set aal = ?, factor_id = ? WHERE user_id = ? AND factor_id = ?", aal, uuid.Nil, userID, factorID).Exec()
+	return tx.RawQuery("UPDATE "+(&pop.Model{Value: Session{}}).TableName()+" set aal = ?, factor_id = ? WHERE user_id = ? AND factor_id = ?", aal, nil, userID, factorID).Exec()
 }
 
 func InvalidateSessionsWithAALLessThan(tx *storage.Connection, userID uuid.UUID, level string) error {
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/api/mfa_test.go b/api/mfa_test.go
index d777bc67..b4c5636f 100644
--- a/api/mfa_test.go
+++ b/api/mfa_test.go
@@ -11,7 +11,6 @@ import (
 	"testing"
 	"time"
 
-	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/conf"
 	"github.com/netlify/gotrue/models"
 	"github.com/netlify/gotrue/utilities"
@@ -329,7 +328,7 @@ func (ts *MFATestSuite) TestUnenrollVerifiedFactor() {
 				require.EqualError(ts.T(), err, models.FactorNotFoundError{}.Error())
 				session, _ := models.FindSessionById(ts.API.db, secondarySession.ID)
 				require.Equal(ts.T(), models.AAL1.String(), session.AAL)
-				require.Equal(ts.T(), &uuid.Nil, session.FactorID)
+				require.Nil(ts.T(), session.FactorID)
 
 			}
 		})
@@ -370,7 +369,7 @@ func (ts *MFATestSuite) TestUnenrollUnverifiedFactor() {
 	require.EqualError(ts.T(), err, models.FactorNotFoundError{}.Error())
 	session, _ := models.FindSessionById(ts.API.db, secondarySession.ID)
 	require.Equal(ts.T(), models.AAL1.String(), session.AAL)
-	require.Equal(ts.T(), &uuid.Nil, session.FactorID)
+	require.Nil(ts.T(), session.FactorID)
 
 }
 
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: "MIIG4wIBAAKCAYEApYkvDaXJEDsELSVosc0sKFnoPeJai8sOu8di5ffGVJRr7mJi+VQjM0d2KeOIllVk2IV58M33Jz2Rx61NYPLu0N9fZqPwbgYn+FNz1L1xgslUL6gyaQnCEKtH5mRqPEBOPvAygq/fZ46eBMs3GSS6NWp/XF/iPaFc1mBDAZFvXev4XV7O6iuqz5mx3rQbkIhMjQxP+IOYWMS4TqueLJWgFUbij0FepJfOE+AlmfBa7xIOyE+g5t3vRB8XwzxRPsljlfgZXstxO1r1NS3DPiUj3kGYy7em5Yb+icIA6xzy0MiwU5RcBSwtVc+M/Yk2tMY6a9z1UX2M5Zr/ih3w0CbW6KDYplqgwwDZv2f+ynIqldn7SjVo3V6fWFu+KtRkofWWkTGjaU2DTpxrxUJEnEo6zXfBSejAjGGAJyKjX74uATlOu/LQEjd5umQpWYvtvP1UkbjHYgITtoTytb3uU7Q7W/YdtNUcaE377QHZF+E+XTCCCw00bCvpDciW+w0JSkRfAgMBAAECggGAR0jCKIBiC0k+zSo04YxXHbFJ34xgLZ7t41NDdYCzuayIpglcUb43wlddvUAsi4COgudH0bkAW7eZ1YD9t2gmC3CFpq+mU9r2z2swkEZcYVPNmxA1VSJMnd0Eg2Ruky+mAlhxh/GwpOm3hpz0RzGXtnT8D42C4cNhNTgS4tP8P1fkhmDTfef8EJZBEIRC8oSfYoYQ0hXpPyDHtakV3mE4pLD303T1CrAMoGaACsCEiDsgfoY75e9gn9c75mlNG1qhhJYxD3Sv1o9lQd3Q1A71sga/E+yIlUcPP4fDaA8DdeH+FHwL9xgQPd18gsrbPdbsg8JMLmjblaz8BB1MvJMwj+b3Ey2idD8CVIq5Ql97TebyMxZp3ZYjLq/R2ay+MpE9Vjgih096Hg+kCPMPi3Q9AmVJX8kN8+2zm2EeDoI/YnJFzmBcmaOuSBEGYdrRk5RCYfZMa1jvpoNUGbWzoX4gRfC7Gr+alaCWa9ot2c+ChWZQlpbKaMYMLU/VEd7gsf/BAoHBANJsSdIxrTUWpdm27SJlq5ylKVokS5ftrxk2gM8M16jLYE7SwPrhdxphWGH8/TMz6/Jyd+CuSfwAzyy325szlFlZVpxv8qu1vWROBaaaq1Tg8cqYC2s+hUTJLevcmiBHFu+7tiYNmMqkNIfj9/FN1zvfPVwqurtB5WXGjI4qhf5SyJgtj1GiM/s9Ae86LiRZhovcEEwf0LddGpMrUEDrWOV9D95sOMA00rsJXOfOg78Ms7Nq/h9w6cnD5x4jUJTMzwKBwQDJY/TMNVa1V8ci+pOMB6iTyi3azVC6ZiCXinCQS0oLebY1GmyWLv9A+/+9Wg/h4p4OdlZSA2/9f6+6njAcxI1wfzHVC3vgF7EDs9YUeAmXWBA171uPHbfimTd21utLkcyJ/WdO4OmKP7ZIK8UWyXE98N5NQV9NRX0sm6CJemwChcoJ8/7lsuYa4nJVUXtAkAMoj7e0nOoWn1IzyolmIXSTrBPiLWh68172tr3ciR6uGN3Yba6szkFTeaBDfNQvk3ECgcEAy07XkKBwwv+L5SxKOFbVlfc6Wh8Bbty2tnyjvemhoTRHbEFTNdOMeU+ezqZamhNLoKgazVp4n2TEx2cpZu5SInYgKew8Is3pHLYJ3axJaCwjUmTPe6Ifr5NVrDMsM42cSqsqVeADRZ+cJcQMtvhHwlByf8/FNdJ4a3qIKYBKkKy5pdc3R1+aK+AJM3QaSwK47f8FPBftWI07dQB/fQonjSvlnjkgKA2hohdszYgKYRhLtEnnGMfHCywd7U+ftvWfAoHAcxfq+SGqkiy+I+FsnWRrFTtAhYE9F6nyCmkV94Dvqis+1I5rbFEjk7Hw7/geh4uJpN5AatKIGCn29gIdoPM7mgU3J3hOrT0c7u7B9CS95n5vlUNb4iirxJanugUNp7yFVn85oTyse1P6CrjpBCLP0wRrJ1+q5XBHH005rBgIzlBDrPiCvidFlivAB75vX/BtvaqU5GWg6pjW0752U6XfB94Z5vLoeQvJQ9ogG39Jx1lyv5O/dgbSErC5xJf8c8whAoHAYdxLfZcDN2IEUgg/czE9jDSJ2EwOCE9HpCntthHAvceK3+PFfpCKwOLynqF8urhdeM510QJK2ETLvzpgMBgSh/klxeBYv8BCL8BuPwyPciAFmPE1Stx7C1+JBF2fayYkCSK9w85INLAJYKTDk9gE8O6l0bXA8tuq3F0tRTwMBcyEpMOehKFamoPcU6cnNa2HC+MyTOfXSBeNZ2VciFYf5rh3YrwoUYbQJtDXxFvoX0Ba+zyneNG0j3epXZuR2lyK",
+		},
+		{
+			// 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")
+}