Repository: supabase/gotrue
Pull request #779 (2022-10-26 11:35:57)

feat(saml): add SAML ACS handler (disabled by default)
Adds the SAML Assertion Consumer Service handler. Only IdP initiated flows can be tested on this PR.

Tests are missing for the ACS endpoint as this requires a bit more time to be invested and will be added in a follow up PR. At this time you can test using samltest.id.

Things that will be done as part of follow up PRs:

- Creating uniqueness domains, i.e. treating SSO accounts as separate from other social login accounts.
- Obeying SAML session duration.


Patch:
diff --git a/api/api.go b/api/api.go
index 219b6ce8..8867b28a 100644
--- a/api/api.go
+++ b/api/api.go
@@ -158,6 +158,13 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 			r.Route("/sso", func(r *router) {
 				r.Route("/saml", func(r *router) {
 					r.Get("/metadata", api.SAMLMetadata)
+
+					r.With(api.limitHandler(
+						// Allow requests at the specified rate per 5 minutes.
+						tollbooth.NewLimiter(api.config.SAML.RateLimitAssertion/(60*5), &limiter.ExpirableOptions{
+							DefaultExpirationTTL: time.Hour,
+						}).SetBurst(30),
+					)).Post("/acs", api.SAMLACS)
 				})
 			})
 		}
diff --git a/api/samlacs.go b/api/samlacs.go
new file mode 100644
index 00000000..da6adb19
--- /dev/null
+++ b/api/samlacs.go
@@ -0,0 +1,269 @@
+package api
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"encoding/xml"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/crewjam/saml"
+	"github.com/gofrs/uuid"
+	"github.com/netlify/gotrue/api/provider"
+	"github.com/netlify/gotrue/models"
+	"github.com/netlify/gotrue/observability"
+	"github.com/netlify/gotrue/storage"
+	"github.com/netlify/gotrue/utilities"
+)
+
+func (a *API) samlDestroyRelayState(ctx context.Context, relayState *models.SAMLRelayState) error {
+	db := a.db.WithContext(ctx)
+
+	// It's OK to destroy the RelayState, as a user will
+	// likely initiate a completely new login flow, instead
+	// of reusing the same one.
+
+	return db.Transaction(func(tx *storage.Connection) error {
+		return tx.Destroy(relayState)
+	})
+}
+
+// SAMLACS implements the main Assertion Consumer Service endpoint behavior.
+func (a *API) SAMLACS(w http.ResponseWriter, r *http.Request) error {
+	ctx := r.Context()
+
+	db := a.db.WithContext(ctx)
+	config := a.config
+	log := observability.GetLogEntry(r)
+
+	relayStateValue := r.FormValue("RelayState")
+	relayStateUUID := uuid.FromStringOrNil(relayStateValue)
+	relayStateURL, _ := url.ParseRequestURI(relayStateValue)
+
+	entityId := ""
+	initiatedBy := ""
+	redirectTo := ""
+	var requestIds []string
+
+	if relayStateUUID != uuid.Nil {
+		// relay state is a valid UUID, therefore this is likely a SP initiated flow
+
+		relayState, err := models.FindSAMLRelayStateByID(db, relayStateUUID)
+		if models.IsNotFoundError(err) {
+			return badRequestError("SAML RelayState does not exist, try logging in again?")
+		} else if err != nil {
+			return err
+		}
+
+		if time.Since(relayState.CreatedAt) >= a.config.SAML.RelayStateValidityPeriod {
+			if err := a.samlDestroyRelayState(ctx, relayState); err != nil {
+				return internalServerError("SAML RelayState has expired and destroying it failed. Try logging in again?").WithInternalError(err)
+			}
+
+			return badRequestError("SAML RelayState has expired. Try loggin in again?")
+		}
+
+		if relayState.FromIPAddress != utilities.GetIPAddress(r) {
+			if err := a.samlDestroyRelayState(ctx, relayState); err != nil {
+				return internalServerError("SAML RelayState comes from another IP address and destroying it failed. Try logging in again?").WithInternalError(err)
+			}
+
+			return badRequestError("SAML RelayState comes from another IP address, try logging in again?")
+		}
+
+		// TODO: add abuse detection to bind the RelayState UUID with a
+		// HTTP-Only cookie
+
+		ssoProvider, err := models.FindSSOProviderByID(db, relayState.SSOProviderID)
+		if err != nil {
+			return internalServerError("Unable to find SSO Provider from SAML RelayState")
+		}
+
+		initiatedBy = "sp"
+		entityId = ssoProvider.SAMLProvider.EntityID
+		redirectTo = relayState.RedirectTo
+		requestIds = append(requestIds, relayState.RequestID)
+
+		if err := a.samlDestroyRelayState(ctx, relayState); err != nil {
+			return err
+		}
+	} else if relayStateValue == "" || relayStateURL != nil {
+		// RelayState may be a URL in which case it's the URL where the
+		// IdP is telling us to redirect the user to
+
+		if r.FormValue("SAMLart") != "" {
+			// SAML Artifact responses are possible only when
+			// RelayState can be used to identify the Identity
+			// Provider.
+			return badRequestError("SAML Artifact response can only be used with SP initiated flow")
+		}
+
+		samlResponse := r.FormValue("SAMLResponse")
+		if samlResponse == "" {
+			return badRequestError("SAMLResponse is missing")
+		}
+
+		responseXML, err := base64.StdEncoding.DecodeString(samlResponse)
+		if err != nil {
+			return badRequestError("SAMLResponse is not a valid Base64 string")
+		}
+
+		var peekResponse saml.Response
+		err = xml.Unmarshal(responseXML, &peekResponse)
+		if err != nil {
+			return badRequestError("SAMLResponse is not a valid XML SAML assertion")
+		}
+
+		initiatedBy = "idp"
+		entityId = peekResponse.Issuer.Value
+		redirectTo = relayStateValue
+	} else {
+		// RelayState can't be identified, so SAML flow can't continue
+		return badRequestError("SAML RelayState is not a valid UUID or URL")
+	}
+
+	ssoProvider, err := models.FindSAMLProviderByEntityID(db, entityId)
+	if models.IsNotFoundError(err) {
+		return badRequestError("A SAML connection has not been established with this Identity Provider")
+	} else if err != nil {
+		return err
+	}
+
+	idpMetadata, err := ssoProvider.SAMLProvider.EntityDescriptor()
+	if err != nil {
+		return err
+	}
+
+	if ssoProvider.SAMLProvider.MetadataURL == nil {
+		if !idpMetadata.ValidUntil.IsZero() && time.Until(idpMetadata.ValidUntil) <= (30*24*60)*time.Second {
+			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.WithField("saml_entity_id", ssoProvider.SAMLProvider.EntityID)
+
+			logentry.Warn("SAML Metadata for identity provider will expire soon! Update its metadata_xml!")
+		}
+	}
+
+	// TODO: fetch new metadata if possible when validUntil < time.Now() or cacheDuration
+
+	serviceProvider := a.getSAMLServiceProvider(idpMetadata, initiatedBy == "idp")
+	spAssertion, err := serviceProvider.ParseResponse(r, requestIds)
+	if err != nil {
+		if ire, ok := err.(*saml.InvalidResponseError); ok {
+			return badRequestError("SAML Assertion is not valid").WithInternalError(ire.PrivateErr)
+		}
+
+		return badRequestError("SAML Assertion is not valid").WithInternalError(err)
+	}
+
+	assertion := SAMLAssertion{
+		spAssertion,
+	}
+
+	userID := assertion.UserID()
+	if userID == "" {
+		return badRequestError("SAML Assertion did not contain a persistent Subject Identifier attribute or Subject NameID uniquely identifying this user")
+	}
+
+	claims := assertion.Process(ssoProvider.SAMLProvider.AttributeMapping)
+
+	email, ok := claims["email"].(string)
+	if !ok || email == "" {
+		// mapping does not identify the email attribute, try to figure it out
+		email = assertion.Email()
+	}
+
+	if email == "" {
+		return badRequestError("SAML Assertion does not contain an email address")
+	} else {
+		claims["email"] = email
+	}
+
+	jsonClaims, err := json.Marshal(claims)
+	if err != nil {
+		return internalServerError("Mapped claims from provider could not be serialized into JSON").WithInternalError(err)
+	}
+
+	providerClaims := &provider.Claims{}
+	if err := json.Unmarshal(jsonClaims, providerClaims); err != nil {
+		return internalServerError("Mapped claims from provider could not be deserialized from JSON").WithInternalError(err)
+	}
+
+	providerClaims.Subject = userID
+	providerClaims.Issuer = ssoProvider.SAMLProvider.EntityID
+	providerClaims.Email = email
+	providerClaims.EmailVerified = true
+
+	providerClaimsMap, err := providerClaims.ToMap()
+	if err != nil {
+		return internalServerError("Parsed provider claims could not be turned into a map").WithInternalError(err)
+	}
+
+	// remove all of the parsed claims, so that the rest can go into CustomClaims
+	for key := range providerClaimsMap {
+		delete(claims, key)
+	}
+
+	providerClaims.CustomClaims = claims
+
+	var userProvidedData provider.UserProvidedData
+
+	userProvidedData.Emails = append(userProvidedData.Emails, provider.Email{
+		Email:    email,
+		Verified: true,
+		Primary:  true,
+	})
+
+	// userProvidedData.Provider.Type = "saml"
+	// userProvidedData.Provider.ID = ssoProvider.ID.String()
+	// userProvidedData.Provider.SAMLEntityID = ssoProvider.SAMLProvider.EntityID
+	// userProvidedData.Provider.SAMLInitiatedBy = initiatedBy
+
+	userProvidedData.Metadata = providerClaims
+
+	// TODO: below
+	// refreshTokenParams.SSOProviderID = ssoProvider.ID
+	// refreshTokenParams.InitiatedByProvider = initiatedBy == "idp"
+	// refreshTokenParams.NotBefore = assertion.NotBefore()
+	// refreshTokenParams.NotAfter = assertion.NotAfter()
+
+	var token *AccessTokenResponse
+
+	if err := db.Transaction(func(tx *storage.Connection) error {
+		var terr error
+		var user *models.User
+
+		if user, terr = a.createAccountFromExternalIdentity(tx, r, &userProvidedData, "sso:"+ssoProvider.ID.String(), user); terr != nil {
+			return terr
+		}
+
+		if config.MFA.Enabled {
+			token, terr = a.MFA_issueRefreshToken(ctx, tx, user, models.SSOSAML, models.GrantParams{})
+		} else {
+			token, terr = a.issueRefreshToken(ctx, tx, user, models.GrantParams{})
+		}
+
+		if terr != nil {
+			return internalServerError("Unable to issue refresh token from SAML Assertion").WithInternalError(terr)
+		}
+
+		return nil
+	}); err != nil {
+		return err
+	}
+
+	if err := a.setCookieTokens(config, token, false, w); err != nil {
+		return internalServerError("Failed to set JWT cookie").WithInternalError(err)
+	}
+
+	if !isRedirectURLValid(config, redirectTo) {
+		redirectTo = config.SiteURL
+	}
+
+	http.Redirect(w, r, token.AsRedirectURL(redirectTo, url.Values{}), http.StatusFound)
+
+	return nil
+}
diff --git a/api/samlassertion.go b/api/samlassertion.go
new file mode 100644
index 00000000..9d456d0e
--- /dev/null
+++ b/api/samlassertion.go
@@ -0,0 +1,167 @@
+package api
+
+import (
+	"strings"
+	"time"
+
+	"github.com/crewjam/saml"
+	"github.com/netlify/gotrue/models"
+)
+
+type SAMLAssertion struct {
+	*saml.Assertion
+}
+
+const (
+	SAMLSubjectIDAttributeName = "urn:oasis:names:tc:SAML:attribute:subject-id"
+)
+
+// Attribute returns the first matching attribute value in the attribute
+// statements where name equals the official SAML attribute Name or
+// FriendlyName. Returns nil if such an attribute can't be found.
+func (a *SAMLAssertion) Attribute(name string) []saml.AttributeValue {
+	var values []saml.AttributeValue
+
+	for _, stmt := range a.AttributeStatements {
+		for _, attr := range stmt.Attributes {
+			// TODO: maybe this should be case-insentivite equality?
+			if attr.Name == name || attr.FriendlyName == name {
+				values = append(values, attr.Values...)
+			}
+		}
+	}
+
+	return values
+}
+
+// UserID returns the best choice for a persistent user identifier on the
+// Identity Provider side. Don't assume the format of the string returned, as
+// it's Identity Provider specific.
+func (a *SAMLAssertion) UserID() string {
+	// First we look up the SAMLSubjectIDAttributeName in the attribute
+	// section of the assertion, as this is the preferred way to
+	// persistently identify users in SAML 2.0.
+	// See: https://docs.oasis-open.org/security/saml-subject-id-attr/v1.0/cs01/saml-subject-id-attr-v1.0-cs01.html#_Toc536097226
+	values := a.Attribute(SAMLSubjectIDAttributeName)
+	if len(values) > 0 {
+		return values[0].Value
+	}
+
+	// Otherwise, fall back to the SubjectID value.
+	subjectID, isPersistent := a.SubjectID()
+	if !isPersistent {
+		return ""
+	}
+
+	return subjectID
+}
+
+// SubjectID returns the user identifier in present in the Subject section of
+// the SAML assertion. Note that this way of identifying the Subject is
+// generally superseded by the SAMLSubjectIDAttributeName assertion attribute;
+// tho must be present in all assertions. It can have a few formats, of which
+// the most important are: saml.EmailAddressNameIDFormat (meaning the user ID
+// is an email address), saml.PersistentNameIDFormat (the user ID is an opaque
+// string that does not change with each assertion, e.g. UUID),
+// saml.TransientNameIDFormat (the user ID changes with each assertion -- can't
+// be used to identify a user). The boolean returned identifies if the user ID
+// is persistent. If it's an email address, it's lowercased just in case.
+func (a *SAMLAssertion) SubjectID() (string, bool) {
+	if a.Subject == nil {
+		return "", false
+	}
+
+	if a.Subject.NameID == nil {
+		return "", false
+	}
+
+	if a.Subject.NameID.Value == "" {
+		return "", false
+	}
+
+	if a.Subject.NameID.Format == string(saml.EmailAddressNameIDFormat) {
+		return strings.ToLower(strings.TrimSpace(a.Subject.NameID.Value)), true
+	}
+
+	// all other NameID formats are regarded as persistent
+	isPersistent := a.Subject.NameID.Format != string(saml.TransientNameIDFormat)
+
+	return a.Subject.NameID.Value, isPersistent
+}
+
+// Email returns the best guess for an email address.
+func (a *SAMLAssertion) Email() string {
+	attributeNames := []string{
+		"urn:oid:0.9.2342.19200300.100.1.3",
+		"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
+		"http://schemas.xmlsoap.org/claims/EmailAddress",
+		"mail",
+		"email",
+	}
+
+	for _, name := range attributeNames {
+		for _, attr := range a.Attribute(name) {
+			if attr.Value != "" {
+				return attr.Value
+			}
+		}
+	}
+
+	if a.Subject.NameID.Format == string(saml.EmailAddressNameIDFormat) {
+		return a.Subject.NameID.Value
+	}
+
+	return ""
+}
+
+// Process processes this assertion according to the SAMLAttributeMapping. Never returns nil.
+func (a *SAMLAssertion) Process(mapping models.SAMLAttributeMapping) map[string]interface{} {
+	ret := make(map[string]interface{})
+
+	for key, mapper := range mapping.Keys {
+		names := []string{mapper.Name}
+		names = append(names, mapper.Names...)
+
+		setKey := false
+
+		for _, name := range names {
+			for _, attr := range a.Attribute(name) {
+				if attr.Value != "" {
+					ret[key] = attr.Value
+					setKey = true
+					break
+				}
+			}
+
+			if setKey {
+				break
+			}
+		}
+
+		if !setKey && mapper.Default != nil {
+			ret[key] = mapper.Default
+		}
+	}
+
+	return ret
+}
+
+// NotBefore extracts the time before which this assertion should not be
+// considered.
+func (a *SAMLAssertion) NotBefore() time.Time {
+	if a.Conditions != nil && !a.Conditions.NotBefore.IsZero() {
+		return a.Conditions.NotBefore.UTC()
+	}
+
+	return time.Time{}
+}
+
+// NotAfter extracts the time at which or after this assertion should not be
+// considered.
+func (a *SAMLAssertion) NotAfter() time.Time {
+	if a.Conditions != nil && !a.Conditions.NotOnOrAfter.IsZero() {
+		return a.Conditions.NotOnOrAfter.UTC()
+	}
+
+	return time.Time{}
+}
diff --git a/conf/saml.go b/conf/saml.go
index 50dd8836..f78ff7f3 100644
--- a/conf/saml.go
+++ b/conf/saml.go
@@ -20,6 +20,8 @@ type SAMLConfiguration struct {
 	RSAPrivateKey *rsa.PrivateKey   `json:"-"`
 	RSAPublicKey  *rsa.PublicKey    `json:"-"`
 	Certificate   *x509.Certificate `json:"-"`
+
+	RateLimitAssertion float64 `default:"15" split_words:"true"`
 }
 
 func (c *SAMLConfiguration) Validate() error {
diff --git a/models/factor.go b/models/factor.go
index 8169b867..8c73d879 100644
--- a/models/factor.go
+++ b/models/factor.go
@@ -24,6 +24,7 @@ const (
 	PasswordGrant
 	OTP
 	TOTPSignIn
+	SSOSAML
 )
 
 func (authMethod AuthenticationMethod) String() string {
@@ -36,6 +37,8 @@ func (authMethod AuthenticationMethod) String() string {
 		return "otp"
 	case TOTPSignIn:
 		return "totp"
+	case SSOSAML:
+		return "sso/saml"
 	}
 	return ""
 }
diff --git a/models/sso.go b/models/sso.go
index f258eb06..1291ccd7 100644
--- a/models/sso.go
+++ b/models/sso.go
@@ -232,3 +232,17 @@ func FindAllSAMLProviders(tx *storage.Connection) ([]SSOProvider, error) {
 
 	return providers, nil
 }
+
+func FindSAMLRelayStateByID(tx *storage.Connection, id uuid.UUID) (*SAMLRelayState, error) {
+	var state SAMLRelayState
+
+	if err := tx.Eager().Q().Where("id = ?", id).First(&state); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, SAMLRelayStateNotFoundError{}
+		}
+
+		return nil, errors.Wrap(err, "error loading SAML Relay State")
+	}
+
+	return &state, nil
+}


Test patch:
diff --git a/api/samlassertion_test.go b/api/samlassertion_test.go
new file mode 100644
index 00000000..606c1901
--- /dev/null
+++ b/api/samlassertion_test.go
@@ -0,0 +1,221 @@
+package api
+
+import (
+	tst "testing"
+
+	"encoding/xml"
+
+	"github.com/crewjam/saml"
+	"github.com/netlify/gotrue/models"
+	"github.com/stretchr/testify/require"
+)
+
+func TestSAMLAssertionUserID(t *tst.T) {
+	type spec struct {
+		xml    string
+		userID string
+	}
+
+	examples := []spec{
+		{
+			xml: `<?xml version="1.0" encoding="UTF-8"?>
+<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xsd="http://www.w3.org/2001/XMLSchema" ID="_72591c79da230cac1457d0ea0f2771ab" IssueInstant="2022-08-11T14:53:38.260Z" Version="2.0">
+	<saml2:Issuer>https://example.com/saml</saml2:Issuer>
+	<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"></ds:Signature>
+	<saml2:Subject>
+		<saml2:NameID xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient" NameQualifier="https://samltest.id/saml/idp" SPNameQualifier="http://localhost:9999/saml/metadata">transient-name-id</saml2:NameID>
+		<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
+			<saml2:SubjectConfirmationData Address="127.0.0.1" NotOnOrAfter="2022-08-11T14:58:38.272Z" Recipient="http://localhost:9999/saml/acs"/>
+		</saml2:SubjectConfirmation>
+	</saml2:Subject>
+	<saml2:Conditions NotBefore="2022-08-11T14:53:38.260Z" NotOnOrAfter="2022-08-11T14:58:38.260Z">
+		<saml2:AudienceRestriction>
+			<saml2:Audience>http://localhost:9999/saml/metadata</saml2:Audience>
+		</saml2:AudienceRestriction>
+	</saml2:Conditions>
+	<saml2:AuthnStatement AuthnInstant="2022-08-11T14:53:34.809Z" SessionIndex="_a5e14df3066529ca462930030712b65a">
+		<saml2:SubjectLocality Address="127.0.0.1"/>
+	<saml2:AuthnContext>
+		<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef>
+	</saml2:AuthnContext>
+	</saml2:AuthnStatement>
+	<saml2:AttributeStatement>
+	</saml2:AttributeStatement>
+</saml2:Assertion>
+`,
+			userID: "",
+		},
+		{
+			xml: `<?xml version="1.0" encoding="UTF-8"?>
+<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xsd="http://www.w3.org/2001/XMLSchema" ID="_72591c79da230cac1457d0ea0f2771ab" IssueInstant="2022-08-11T14:53:38.260Z" Version="2.0">
+	<saml2:Issuer>https://example.com/saml</saml2:Issuer>
+	<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"></ds:Signature>
+	<saml2:Subject>
+		<saml2:NameID xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" NameQualifier="https://example.com/saml" SPNameQualifier="http://localhost:9999/saml/metadata">persistent-name-id</saml2:NameID>
+		<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
+			<saml2:SubjectConfirmationData Address="79.125.170.79" NotOnOrAfter="2022-08-11T14:58:38.272Z" Recipient="http://localhost:9999/saml/acs"/>
+		</saml2:SubjectConfirmation>
+	</saml2:Subject>
+	<saml2:Conditions NotBefore="2022-08-11T14:53:38.260Z" NotOnOrAfter="2022-08-11T14:58:38.260Z">
+		<saml2:AudienceRestriction>
+			<saml2:Audience>http://localhost:9999/saml/metadata</saml2:Audience>
+		</saml2:AudienceRestriction>
+	</saml2:Conditions>
+	<saml2:AuthnStatement AuthnInstant="2022-08-11T14:53:34.809Z" SessionIndex="_a5e14df3066529ca462930030712b65a">
+		<saml2:SubjectLocality Address="127.0.0.1"/>
+	<saml2:AuthnContext>
+		<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef>
+	</saml2:AuthnContext>
+	</saml2:AuthnStatement>
+	<saml2:AttributeStatement>
+	</saml2:AttributeStatement>
+</saml2:Assertion>
+`,
+			userID: "persistent-name-id",
+		},
+		{
+			xml: `<?xml version="1.0" encoding="UTF-8"?>
+<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xsd="http://www.w3.org/2001/XMLSchema" ID="_72591c79da230cac1457d0ea0f2771ab" IssueInstant="2022-08-11T14:53:38.260Z" Version="2.0">
+	<saml2:Issuer>https://example.com/saml</saml2:Issuer>
+	<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"></ds:Signature>
+	<saml2:Subject>
+		<saml2:NameID xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress" NameQualifier="https://example.com/saml" SPNameQualifier="http://localhost:9999/saml/metadata">name-id@example.com</saml2:NameID>
+		<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
+			<saml2:SubjectConfirmationData Address="79.125.170.79" NotOnOrAfter="2022-08-11T14:58:38.272Z" Recipient="http://localhost:9999/saml/acs"/>
+		</saml2:SubjectConfirmation>
+	</saml2:Subject>
+	<saml2:Conditions NotBefore="2022-08-11T14:53:38.260Z" NotOnOrAfter="2022-08-11T14:58:38.260Z">
+		<saml2:AudienceRestriction>
+			<saml2:Audience>http://localhost:9999/saml/metadata</saml2:Audience>
+		</saml2:AudienceRestriction>
+	</saml2:Conditions>
+	<saml2:AuthnStatement AuthnInstant="2022-08-11T14:53:34.809Z" SessionIndex="_a5e14df3066529ca462930030712b65a">
+		<saml2:SubjectLocality Address="127.0.0.1"/>
+	<saml2:AuthnContext>
+		<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef>
+	</saml2:AuthnContext>
+	</saml2:AuthnStatement>
+	<saml2:AttributeStatement>
+	</saml2:AttributeStatement>
+</saml2:Assertion>
+`,
+			userID: "name-id@example.com",
+		},
+		{
+			xml: `<?xml version="1.0" encoding="UTF-8"?>
+<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xsd="http://www.w3.org/2001/XMLSchema" ID="_72591c79da230cac1457d0ea0f2771ab" IssueInstant="2022-08-11T14:53:38.260Z" Version="2.0">
+	<saml2:Issuer>https://example.com/saml</saml2:Issuer>
+	<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"></ds:Signature>
+	<saml2:Subject>
+		<saml2:NameID xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress" NameQualifier="https://example.com/saml" SPNameQualifier="http://localhost:9999/saml/metadata">name-id@example.com</saml2:NameID>
+		<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
+			<saml2:SubjectConfirmationData Address="79.125.170.79" NotOnOrAfter="2022-08-11T14:58:38.272Z" Recipient="http://localhost:9999/saml/acs"/>
+		</saml2:SubjectConfirmation>
+	</saml2:Subject>
+	<saml2:Conditions NotBefore="2022-08-11T14:53:38.260Z" NotOnOrAfter="2022-08-11T14:58:38.260Z">
+		<saml2:AudienceRestriction>
+			<saml2:Audience>http://localhost:9999/saml/metadata</saml2:Audience>
+		</saml2:AudienceRestriction>
+	</saml2:Conditions>
+	<saml2:AuthnStatement AuthnInstant="2022-08-11T14:53:34.809Z" SessionIndex="_a5e14df3066529ca462930030712b65a">
+		<saml2:SubjectLocality Address="127.0.0.1"/>
+	<saml2:AuthnContext>
+		<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef>
+	</saml2:AuthnContext>
+	</saml2:AuthnStatement>
+	<saml2:AttributeStatement>
+		<saml2:Attribute Name="urn:oasis:names:tc:SAML:attribute:subject-id" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+			<saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xsd:string">subject-id</saml2:AttributeValue>
+		</saml2:Attribute>
+	</saml2:AttributeStatement>
+</saml2:Assertion>
+`,
+			userID: "subject-id",
+		},
+	}
+
+	for i, example := range examples {
+		rawAssertion := saml.Assertion{}
+		require.NoError(t, xml.Unmarshal([]byte(example.xml), &rawAssertion))
+
+		assertion := SAMLAssertion{
+			&rawAssertion,
+		}
+
+		userID := assertion.UserID()
+
+		require.Equal(t, userID, example.userID, "example %d had different user ID", i)
+	}
+}
+
+func TestSAMLAssertionProcessing(t *tst.T) {
+	type spec struct {
+		xml      string
+		mapping  models.SAMLAttributeMapping
+		expected map[string]interface{}
+	}
+
+	examples := []spec{
+		{
+			xml: `<?xml version="1.0" encoding="UTF-8"?>
+<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xsd="http://www.w3.org/2001/XMLSchema" ID="_72591c79da230cac1457d0ea0f2771ab" IssueInstant="2022-08-11T14:53:38.260Z" Version="2.0">
+	<saml2:AttributeStatement>
+		<saml2:Attribute Name="urn:oid:0.9.2342.19200300.100.1.3" FriendlyName="mail" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+			<saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xsd:string">someone@example.com</saml2:AttributeValue>
+		</saml2:Attribute>
+	</saml2:AttributeStatement>
+</saml2:Assertion>
+`,
+			mapping: models.SAMLAttributeMapping{
+				Keys: map[string]models.SAMLAttribute{
+					"email": {
+						Name: "mail",
+					},
+				},
+			},
+			expected: map[string]interface{}{
+				"email": "someone@example.com",
+			},
+		},
+		{
+			xml: `<?xml version="1.0" encoding="UTF-8"?>
+<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xsd="http://www.w3.org/2001/XMLSchema" ID="_72591c79da230cac1457d0ea0f2771ab" IssueInstant="2022-08-11T14:53:38.260Z" Version="2.0">
+	<saml2:AttributeStatement>
+		<saml2:Attribute Name="http://schemas.xmlsoap.org/claims/EmailAddress" FriendlyName="mail" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+			<saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xsd:string">old-soap@example.com</saml2:AttributeValue>
+		</saml2:Attribute>
+		<saml2:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" FriendlyName="mail" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+			<saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xsd:string">soap@example.com</saml2:AttributeValue>
+		</saml2:Attribute>
+	</saml2:AttributeStatement>
+</saml2:Assertion>
+`,
+			mapping: models.SAMLAttributeMapping{
+				Keys: map[string]models.SAMLAttribute{
+					"email": {
+						Names: []string{
+							"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
+							"http://schemas.xmlsoap.org/claims/EmailAddress",
+						},
+					},
+				},
+			},
+			expected: map[string]interface{}{
+				"email": "soap@example.com",
+			},
+		},
+	}
+
+	for i, example := range examples {
+		rawAssertion := saml.Assertion{}
+		require.NoError(t, xml.Unmarshal([]byte(example.xml), &rawAssertion))
+
+		assertion := SAMLAssertion{
+			&rawAssertion,
+		}
+
+		result := assertion.Process(example.mapping)
+
+		require.Equal(t, result, example.expected, "example %d had different processing", i)
+	}
+}