Repository: supabase/gotrue
Pull request #833 (2022-11-28 17:52:15)

fix: fetch new IDP metadata if stale
## What kind of change does this PR introduce?

This PR adds the ability to detect stale metadata(via entity desciptors) and the ability to fetch new metadata to replace stale metadata. This is useful for certain IDPs which require the latest metadata in order to function.

Implementation details:

If the existing metadata has a `ValidUntil` or `CacheDuration` Entity Descriptor as per [the spec, page 8](https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf) we want to fetch new metadata XML and save it in the database.

SideNote:

- We remove the EditRole functions as `staticcheck` has recently started flagging it as unused for some reason



TODO:
 - [x] Write tests





Patch:
diff --git a/api/admin.go b/api/admin.go
index 68f54d73..d143a25a 100644
--- a/api/admin.go
+++ b/api/admin.go
@@ -148,14 +148,14 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error {
 	}
 
 	if params.Email != "" {
-		params.Email, err = a.validateEmail(ctx, params.Email)
+		params.Email, err = validateEmail(params.Email)
 		if err != nil {
 			return err
 		}
 	}
 
 	if params.Phone != "" {
-		params.Phone, err = a.validatePhone(params.Phone)
+		params.Phone, err = validatePhone(params.Phone)
 		if err != nil {
 			return err
 		}
@@ -275,7 +275,7 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error {
 	}
 
 	if params.Email != "" {
-		params.Email, err = a.validateEmail(ctx, params.Email)
+		params.Email, err = validateEmail(params.Email)
 		if err != nil {
 			return err
 		}
@@ -287,7 +287,7 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error {
 	}
 
 	if params.Phone != "" {
-		params.Phone, err = a.validatePhone(params.Phone)
+		params.Phone, err = validatePhone(params.Phone)
 		if err != nil {
 			return err
 		}
diff --git a/api/invite.go b/api/invite.go
index 699fcaed..ae0cebd4 100644
--- a/api/invite.go
+++ b/api/invite.go
@@ -33,7 +33,7 @@ func (a *API) Invite(w http.ResponseWriter, r *http.Request) error {
 		return badRequestError("Could not read Invite params: %v", err)
 	}
 
-	params.Email, err = a.validateEmail(ctx, params.Email)
+	params.Email, err = validateEmail(params.Email)
 	if err != nil {
 		return err
 	}
diff --git a/api/magic_link.go b/api/magic_link.go
index fdd35bfc..8dd5f6e9 100644
--- a/api/magic_link.go
+++ b/api/magic_link.go
@@ -43,7 +43,7 @@ func (a *API) MagicLink(w http.ResponseWriter, r *http.Request) error {
 	if params.Email == "" {
 		return unprocessableEntityError("Password recovery requires an email")
 	}
-	params.Email, err = a.validateEmail(ctx, params.Email)
+	params.Email, err = validateEmail(params.Email)
 	if err != nil {
 		return err
 	}
diff --git a/api/mail.go b/api/mail.go
index c2b5bb60..9d1df8df 100644
--- a/api/mail.go
+++ b/api/mail.go
@@ -1,7 +1,6 @@
 package api
 
 import (
-	"context"
 	"crypto/sha256"
 	"encoding/json"
 	"fmt"
@@ -9,6 +8,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/badoux/checkmail"
 	"github.com/fatih/structs"
 	"github.com/netlify/gotrue/api/provider"
 	"github.com/netlify/gotrue/conf"
@@ -60,7 +60,7 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
 		return badRequestError("Could not parse JSON: %v", err)
 	}
 
-	params.Email, err = a.validateEmail(ctx, params.Email)
+	params.Email, err = validateEmail(params.Email)
 	if err != nil {
 		return err
 	}
@@ -178,7 +178,7 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
 			if !config.Mailer.SecureEmailChangeEnabled && params.Type == "email_change_current" {
 				return unprocessableEntityError("Enable secure email change to generate link for current email")
 			}
-			params.NewEmail, terr = a.validateEmail(ctx, params.NewEmail)
+			params.NewEmail, terr = validateEmail(params.NewEmail)
 			if terr != nil {
 				return unprocessableEntityError("The new email address provided is invalid")
 			}
@@ -372,12 +372,11 @@ func (a *API) sendEmailChange(tx *storage.Connection, config *conf.GlobalConfigu
 	), "Database error updating user for email change")
 }
 
-func (a *API) validateEmail(ctx context.Context, email string) (string, error) {
+func validateEmail(email string) (string, error) {
 	if email == "" {
 		return "", unprocessableEntityError("An email address is required")
 	}
-	mailer := a.Mailer(ctx)
-	if err := mailer.ValidateEmail(email); err != nil {
+	if err := checkmail.ValidateFormat(email); err != nil {
 		return "", unprocessableEntityError("Unable to validate email address: " + err.Error())
 	}
 	return strings.ToLower(email), nil
diff --git a/api/otp.go b/api/otp.go
index 8e4e1606..7af296bd 100644
--- a/api/otp.go
+++ b/api/otp.go
@@ -87,7 +87,7 @@ func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error {
 		params.Data = make(map[string]interface{})
 	}
 
-	params.Phone, err = a.validatePhone(params.Phone)
+	params.Phone, err = validatePhone(params.Phone)
 	if err != nil {
 		return err
 	}
@@ -179,13 +179,13 @@ func (a *API) shouldCreateUser(r *http.Request, params *OtpParams) (bool, error)
 		aud := a.requestAud(ctx, r)
 		var err error
 		if params.Email != "" {
-			params.Email, err = a.validateEmail(ctx, params.Email)
+			params.Email, err = validateEmail(params.Email)
 			if err != nil {
 				return false, err
 			}
 			_, err = models.FindUserByEmailAndAudience(db, params.Email, aud)
 		} else if params.Phone != "" {
-			params.Phone, err = a.validatePhone(params.Phone)
+			params.Phone, err = validatePhone(params.Phone)
 			if err != nil {
 				return false, err
 			}
diff --git a/api/phone.go b/api/phone.go
index f776dd6d..4b684787 100644
--- a/api/phone.go
+++ b/api/phone.go
@@ -23,23 +23,23 @@ const (
 	phoneReauthenticationOtp = "reauthentication"
 )
 
-func (a *API) validatePhone(phone string) (string, error) {
-	phone = a.formatPhoneNumber(phone)
-	if isValid := a.validateE164Format(phone); !isValid {
+func validatePhone(phone string) (string, error) {
+	phone = formatPhoneNumber(phone)
+	if isValid := validateE164Format(phone); !isValid {
 		return "", unprocessableEntityError("Invalid phone number format")
 	}
 	return phone, nil
 }
 
 // validateE164Format checks if phone number follows the E.164 format
-func (a *API) validateE164Format(phone string) bool {
+func validateE164Format(phone string) bool {
 	// match should never fail as long as regexp is valid
 	matched, _ := regexp.Match(e164Format, []byte(phone))
 	return matched
 }
 
 // formatPhoneNumber removes "+" and whitespaces in a phone number
-func (a *API) formatPhoneNumber(phone string) string {
+func formatPhoneNumber(phone string) string {
 	return strings.ReplaceAll(strings.Trim(phone, "+"), " ", "")
 }
 
diff --git a/api/recover.go b/api/recover.go
index b8e0c694..d5c5a95b 100644
--- a/api/recover.go
+++ b/api/recover.go
@@ -37,7 +37,7 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error {
 	var user *models.User
 	aud := a.requestAud(ctx, r)
 
-	params.Email, err = a.validateEmail(ctx, params.Email)
+	params.Email, err = validateEmail(params.Email)
 	if err != nil {
 		return err
 	}
diff --git a/api/samlacs.go b/api/samlacs.go
index f6e82f4c..d379cec9 100644
--- a/api/samlacs.go
+++ b/api/samlacs.go
@@ -31,6 +31,12 @@ func (a *API) samlDestroyRelayState(ctx context.Context, relayState *models.SAML
 	})
 }
 
+func IsMetadataStale(idpMetadata *saml.EntityDescriptor, samlProvider models.SAMLProvider) bool {
+	hasIDPMetadataExpired := !idpMetadata.ValidUntil.IsZero() && idpMetadata.ValidUntil.Before(time.Now())
+	hasCacheDurationExceeded := idpMetadata.CacheDuration != 0 && samlProvider.UpdatedAt.Add(idpMetadata.CacheDuration).Before(time.Now())
+	return hasIDPMetadataExpired || hasCacheDurationExceeded
+}
+
 // SAMLACS implements the main Assertion Consumer Service endpoint behavior.
 func (a *API) SAMLACS(w http.ResponseWriter, r *http.Request) error {
 	ctx := r.Context()
@@ -148,7 +154,23 @@ func (a *API) SAMLACS(w http.ResponseWriter, r *http.Request) error {
 		}
 	}
 
-	// TODO: fetch new metadata if possible when validUntil < time.Now() or cacheDuration
+	var samlMetadataModified bool
+	samlMetadataModified = false
+	if *ssoProvider.SAMLProvider.MetadataURL != "" && IsMetadataStale(idpMetadata, ssoProvider.SAMLProvider) {
+		rawMetadata, err := fetchSAMLMetadata(ctx, *ssoProvider.SAMLProvider.MetadataURL)
+		if err != nil {
+			// Fail silently but raise warning and continue with existing metadata
+			logentry := log.WithField("sso_provider_id", ssoProvider.ID.String())
+			logentry = logentry.WithField("expires_in", time.Until(idpMetadata.ValidUntil).String())
+			logentry = logentry.WithField("valid_until", idpMetadata.ValidUntil)
+			logentry = logentry.WithError(err)
+			logentry.Warn("SAML Metadata could not be retrieved, continuing with existing metadata")
+		} else {
+			ssoProvider.SAMLProvider.MetadataXML = string(rawMetadata)
+			samlMetadataModified = true
+		}
+
+	}
 
 	serviceProvider := a.getSAMLServiceProvider(idpMetadata, initiatedBy == "idp")
 	spAssertion, err := serviceProvider.ParseResponse(r, requestIds)
@@ -237,6 +259,11 @@ func (a *API) SAMLACS(w http.ResponseWriter, r *http.Request) error {
 	}
 
 	var token *AccessTokenResponse
+	if samlMetadataModified {
+		if err := a.db.Update(ssoProvider.SAMLProvider); err != nil {
+			return err
+		}
+	}
 
 	if err := db.Transaction(func(tx *storage.Connection) error {
 		var terr error
diff --git a/api/signup.go b/api/signup.go
index 25a9cc6e..8d6dbe65 100644
--- a/api/signup.go
+++ b/api/signup.go
@@ -75,7 +75,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 		if !config.External.Email.Enabled {
 			return badRequestError("Email signups are disabled")
 		}
-		params.Email, err = a.validateEmail(ctx, params.Email)
+		params.Email, err = validateEmail(params.Email)
 		if err != nil {
 			return err
 		}
@@ -84,7 +84,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 		if !config.External.Phone.Enabled {
 			return badRequestError("Phone signups are disabled")
 		}
-		params.Phone, err = a.validatePhone(params.Phone)
+		params.Phone, err = validatePhone(params.Phone)
 		if err != nil {
 			return err
 		}
diff --git a/api/token.go b/api/token.go
index db763425..8965958c 100644
--- a/api/token.go
+++ b/api/token.go
@@ -70,8 +70,6 @@ type IdTokenGrantParams struct {
 	IdToken  string `json:"id_token"`
 	Nonce    string `json:"nonce"`
 	Provider string `json:"provider"`
-	ClientID string `json:"client_id"`
-	Issuer   string `json:"issuer"`
 }
 
 const useCookieHeader = "x-use-cookie"
@@ -125,16 +123,6 @@ func (p *IdTokenGrantParams) getVerifier(ctx context.Context, config *conf.Globa
 	return provider.Verifier(&oidc.Config{ClientID: oAuthProviderClientId}), nil
 }
 
-func (p *IdTokenGrantParams) getVerifierFromClientIDandIssuer(ctx context.Context) (*oidc.IDTokenVerifier, error) {
-	var provider *oidc.Provider
-	var err error
-	provider, err = oidc.NewProvider(ctx, p.Issuer)
-	if err != nil {
-		return nil, fmt.Errorf("issuer %s doesn't support the id_token grant flow", p.Issuer)
-	}
-	return provider.Verifier(&oidc.Config{ClientID: p.ClientID}), nil
-}
-
 func getEmailVerified(v interface{}) bool {
 	var emailVerified bool
 	var err error
@@ -204,7 +192,7 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri
 		if !config.External.Phone.Enabled {
 			return badRequestError("Phone logins are disabled")
 		}
-		params.Phone = a.formatPhoneNumber(params.Phone)
+		params.Phone = formatPhoneNumber(params.Phone)
 		user, err = models.FindUserByPhoneAndAudience(db, params.Phone, aud)
 	} else {
 		return oauthError("invalid_grant", InvalidLoginMessage)
@@ -404,18 +392,11 @@ func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.R
 		return oauthError("invalid request", "id_token required")
 	}
 
-	if params.Provider == "" && (params.ClientID == "" || params.Issuer == "") {
-		return oauthError("invalid request", "provider or client_id and issuer required")
+	if params.Provider == "" {
+		return oauthError("invalid request", "provider required")
 	}
 
-	var verifier *oidc.IDTokenVerifier
-	if params.Provider != "" {
-		verifier, err = params.getVerifier(ctx, a.config)
-	} else if params.ClientID != "" && params.Issuer != "" {
-		verifier, err = params.getVerifierFromClientIDandIssuer(ctx)
-	} else {
-		return badRequestError("%v", err)
-	}
+	verifier, err := params.getVerifier(ctx, a.config)
 	if err != nil {
 		return err
 	}
diff --git a/api/user.go b/api/user.go
index 62e4b1ef..153ba20d 100644
--- a/api/user.go
+++ b/api/user.go
@@ -124,7 +124,7 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 		}
 
 		if params.Email != "" && params.Email != user.GetEmail() {
-			params.Email, terr = a.validateEmail(ctx, params.Email)
+			params.Email, terr = validateEmail(params.Email)
 			if terr != nil {
 				return terr
 			}
@@ -144,7 +144,7 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 		}
 
 		if params.Phone != "" && params.Phone != user.GetPhone() {
-			params.Phone, err = a.validatePhone(params.Phone)
+			params.Phone, err = validatePhone(params.Phone)
 			if err != nil {
 				return err
 			}
diff --git a/api/verify.go b/api/verify.go
index 98e19b25..b10a717c 100644
--- a/api/verify.go
+++ b/api/verify.go
@@ -450,7 +450,7 @@ func (a *API) verifyUserAndToken(ctx context.Context, conn *storage.Connection,
 	var err error
 	var tokenHash string
 	if isPhoneOtpVerification(params) {
-		params.Phone, err = a.validatePhone(params.Phone)
+		params.Phone, err = validatePhone(params.Phone)
 		if err != nil {
 			return nil, err
 		}
@@ -464,7 +464,7 @@ func (a *API) verifyUserAndToken(ctx context.Context, conn *storage.Connection,
 			return nil, badRequestError("Invalid sms verification type")
 		}
 	} else if isEmailOtpVerification(params) {
-		params.Email, err = a.validateEmail(ctx, params.Email)
+		params.Email, err = validateEmail(params.Email)
 		if err != nil {
 			return nil, unprocessableEntityError("Invalid email format").WithInternalError(err)
 		}
diff --git a/cmd/admin_cmd.go b/cmd/admin_cmd.go
index 599fc842..2dc65dcc 100644
--- a/cmd/admin_cmd.go
+++ b/cmd/admin_cmd.go
@@ -58,13 +58,6 @@ var adminDeleteUserCmd = cobra.Command{
 	},
 }
 
-var adminEditRoleCmd = cobra.Command{
-	Use: "editrole",
-	Run: func(cmd *cobra.Command, args []string) {
-		execWithConfigAndArgs(cmd, adminEditRole, args)
-	},
-}
-
 func adminCreateUser(config *conf.GlobalConfiguration, args []string) {
 	db, err := storage.Dial(config)
 	if err != nil {
@@ -136,32 +129,3 @@ func adminDeleteUser(config *conf.GlobalConfiguration, args []string) {
 
 	logrus.Infof("Removed user: %s", args[0])
 }
-
-func adminEditRole(config *conf.GlobalConfiguration, args []string) {
-	db, err := storage.Dial(config)
-	if err != nil {
-		logrus.Fatalf("Error opening database: %+v", err)
-	}
-	defer db.Close()
-
-	user, err := models.FindUserByEmailAndAudience(db, args[0], getAudience(config))
-	if err != nil {
-		userID := uuid.Must(uuid.FromString(args[0]))
-		user, err = models.FindUserByID(db, userID)
-		if err != nil {
-			logrus.Fatalf("Error finding user (%s): %+v", userID, err)
-		}
-	}
-
-	if len(args) > 0 {
-		user.Role = args[0]
-	} else if isAdmin {
-		user.Role = config.JWT.AdminGroupName
-	}
-
-	if err = db.UpdateOnly(user, "role", "is_super_admin"); err != nil {
-		logrus.Fatalf("Error updating role for user (%s): %+v", args[0], err)
-	}
-
-	logrus.Infof("Updated user: %s", args[0])
-}
diff --git a/migrations/20221011041400_add_mfa_indexes.up.sql b/migrations/20221011041400_add_mfa_indexes.up.sql
index 25d3a863..def57a29 100644
--- a/migrations/20221011041400_add_mfa_indexes.up.sql
+++ b/migrations/20221011041400_add_mfa_indexes.up.sql
@@ -5,8 +5,9 @@ do $$
 begin
   if not exists
      (select constraint_name
-      from information_schema.check_constraints
-      where constraint_schema = '{{ index .Options "Namespace" }}'
+      from information_schema.table_constraints
+      where table_schema = '{{ index .Options "Namespace" }}'
+      and table_name = 'mfa_amr_claims'
       and constraint_name = 'amr_id_pk')
   then
     alter table {{ index .Options "Namespace" }}.mfa_amr_claims add constraint amr_id_pk primary key(id);
diff --git a/openapi.yaml b/openapi.yaml
index 8660dad5..280ab051 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -90,7 +90,7 @@ paths:
               description: |-
                 For the refresh token flow, supply only `refresh_token`.
                 For the email/phone with password flow, supply `email`, `phone` and `password` with an optional `gotrue_meta_security`.
-                For the OIDC ID token flow, supply `id_token`, `nonce`, `provider`, `client_id`, `issuer` with an optional `gotrue_meta_security`.
+                For the OIDC ID token flow, supply `id_token`, `nonce`, `provider` with an optional `gotrue_meta_security`.
               properties:
                 refresh_token:
                   type: string
@@ -111,10 +111,6 @@ paths:
                   enum:
                     - google
                     - apple
-                client_id:
-                  type: string
-                issuer:
-                  type: string
                 gotrue_meta_security:
                   $ref: '#/components/schemas/GoTrueMetaSecurity'
       responses:


Test patch:
diff --git a/api/phone_test.go b/api/phone_test.go
index 7057746e..63fe361f 100644
--- a/api/phone_test.go
+++ b/api/phone_test.go
@@ -56,12 +56,12 @@ func (ts *PhoneTestSuite) SetupTest() {
 }
 
 func (ts *PhoneTestSuite) TestValidateE164Format() {
-	isValid := ts.API.validateE164Format("0123456789")
+	isValid := validateE164Format("0123456789")
 	assert.Equal(ts.T(), false, isValid)
 }
 
 func (ts *PhoneTestSuite) TestFormatPhoneNumber() {
-	actual := ts.API.formatPhoneNumber("+1 23456789 ")
+	actual := formatPhoneNumber("+1 23456789 ")
 	assert.Equal(ts.T(), "123456789", actual)
 }
 
diff --git a/api/sso_test.go b/api/sso_test.go
index 7a105e35..544c890a 100644
--- a/api/sso_test.go
+++ b/api/sso_test.go
@@ -9,6 +9,7 @@ import (
 	"net/http/httptest"
 	"net/url"
 	"testing"
+	"time"
 
 	jwt "github.com/golang-jwt/jwt"
 	"github.com/netlify/gotrue/conf"
@@ -17,6 +18,10 @@ import (
 	"github.com/stretchr/testify/suite"
 )
 
+const dateInPast = "2001-02-03T04:05:06.789"
+const dateInFarFuture = "2999-02-03T04:05:06.789"
+const oneHour = "PT1H"
+
 type SSOTestSuite struct {
 	suite.Suite
 	API      *API
@@ -100,6 +105,89 @@ func (ts *SSOTestSuite) TestAdminGetSSOProviderNotExist() {
 	}
 }
 
+func configurableSAMLIDPMetadata(entityID, validUntil, cacheDuration string) string {
+	return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?><md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="%s" validUntil='%s' cacheDuration='%s'>
+  <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
+    <md:KeyDescriptor use="signing">
+      <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
+        <ds:X509Data>
+          <ds:X509Certificate>MIIDdDCCAlygAwIBAgIGAYKSjRZiMA0GCSqGSIb3DQEBCwUAMHsxFDASBgNVBAoTC0dvb2dsZSBJ
+bmMuMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MQ8wDQYDVQQDEwZHb29nbGUxGDAWBgNVBAsTD0dv
+b2dsZSBGb3IgV29yazELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWEwHhcNMjIwODEy
+MTQ1NDU1WhcNMjcwODExMTQ1NDU1WjB7MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEWMBQGA1UEBxMN
+TW91bnRhaW4gVmlldzEPMA0GA1UEAxMGR29vZ2xlMRgwFgYDVQQLEw9Hb29nbGUgRm9yIFdvcmsx
+CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
+MIIBCgKCAQEAlncFzErcnZm7ZWO71NZStnCIAoYNKf6Uw3LPLzcvk0YrA/eBC3PVDHSfahi+apGO
+Ytdq7IQUvBdto3rJTvP49fjyO0WLbAbiPC+dILt2Gx9kttxpSp99Bf+8ObL/fTy5Y2oHbJBfBX1V
+qfDQIY0fcej3AndFYUOE0gZXyeSbnROB8W1PzHxOc7rq1mlas0rvyja7AK4gwXjIwyIGsFDmHnve
+buqWOYMzOT9oD+iQq9BWYVHkXGZn0BXzKtnw9w8I3IxQdndUoCl95pYRIvdl1b0dWdO9cXtSsTkL
+kAa8B/mCQcF4W2M3t/yKtrcLcRTALg3/Hc+Xz+3BpY/fSDk1SwIDAQABMA0GCSqGSIb3DQEBCwUA
+A4IBAQCER02WLf6bKwTGVD/3VTntetIiETuPs46Dum8blbsg+2BYdAHIQcB9cLuMRosIw0nYj54m
+SfiyfoWGcx3CkMup1MtKyWu+SqDHl9Bpf+GFLG0ngKD/zB6xwpv/TCi+g/FBYe2TvzD6B1V0z7Vs
+Xf+Gc2TWBKmCuKf/g2AUt7IQLpOaqxuJVoZjp4sEMov6d3FnaoHQEd0lg+XmnYfLNtwe3QRSU0BD
+x6lVV4kXi0x0n198/gkjnA85rPZoZ6dmqHtkcM0Gabgg6KEE5ubSDlWDsdv27uANceCZAoxd1+in
+4/KqqkhynnbJs7Op5ZX8cckiHGGTGHNb35kys/XukuCo</ds:X509Certificate>
+        </ds:X509Data>
+      </ds:KeyInfo>
+    </md:KeyDescriptor>
+    <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
+    <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="%s"/>
+    <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="%s"/>
+  </md:IDPSSODescriptor>
+</md:EntityDescriptor>`, entityID, validUntil, cacheDuration, entityID, entityID)
+
+}
+
+func (ts *SSOTestSuite) TestIsStaleSAMLMetadata() {
+
+	// https://en.wikipedia.org/wiki/ISO_8601
+	currentTime := time.Now()
+	currentTimeAsISO8601 := currentTime.UTC().Format("2006-01-02T15:04:05Z07:00")
+	examples := []struct {
+		Description           string
+		Metadata              []byte
+		IsStale               bool
+		CacheDurationExceeded bool
+	}{
+		{
+			Description:           "Metadata is valid and within cache duration",
+			Metadata:              []byte(configurableSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-B", dateInFarFuture, oneHour)),
+			IsStale:               false,
+			CacheDurationExceeded: false,
+		},
+		{
+
+			Description:           "Metadata is valid but is a minute past cache duration",
+			Metadata:              []byte(configurableSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-B", currentTimeAsISO8601, oneHour)),
+			IsStale:               true,
+			CacheDurationExceeded: true,
+		},
+
+		{
+			Description:           "Metadata is invalid but within cache duration",
+			Metadata:              []byte(configurableSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-B", dateInPast, oneHour)),
+			IsStale:               true,
+			CacheDurationExceeded: false,
+		},
+	}
+
+	for _, example := range examples {
+		metadata, err := parseSAMLMetadata(example.Metadata)
+		require.NoError(ts.T(), err)
+		provider := models.SAMLProvider{
+			EntityID:    metadata.EntityID,
+			MetadataXML: string(example.Metadata),
+			UpdatedAt:   currentTime,
+		}
+		if example.CacheDurationExceeded {
+			provider.UpdatedAt = currentTime.Add(-time.Minute * 59)
+		}
+
+		require.Equal(ts.T(), example.IsStale, IsMetadataStale(metadata, provider))
+	}
+
+}
+
 func validSAMLIDPMetadata(entityID string) string {
 	return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?><md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="%s">
   <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">