zitadel/internal/idp/providers/saml/saml.go
Livio Spring e57a9b57c8
feat(saml): allow setting nameid-format and alternative mapping for transient format (#7979)
# Which Problems Are Solved

ZITADEL currently always uses
`urn:oasis:names:tc:SAML:2.0:nameid-format:persistent` in SAML requests,
relying on the IdP to respect that flag and always return a peristent
nameid in order to be able to map the external user with an existing
user (idp link) in ZITADEL.
In case the IdP however returns a
`urn:oasis:names:tc:SAML:2.0:nameid-format:transient` (transient)
nameid, the attribute will differ between each request and it will not
be possible to match existing users.

# How the Problems Are Solved

This PR adds the following two options on SAML IdP:
- **nameIDFormat**: allows to set the nameid-format used in the SAML
Request
- **transientMappingAttributeName**: allows to set an attribute name,
which will be used instead of the nameid itself in case the returned
nameid-format is transient

# Additional Changes

To reduce impact on current installations, the `idp_templates6_saml`
table is altered with the two added columns by a setup job. New
installations will automatically get the table with the two columns
directly.
All idp unit tests are updated to use `expectEventstore` instead of the
deprecated `eventstoreExpect`.

# Additional Context

Closes #7483
Closes #7743

---------

Co-authored-by: peintnermax <max@caos.ch>
Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com>
2024-05-23 05:04:07 +00:00

216 lines
4.9 KiB
Go

package saml
import (
"context"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/xml"
"net/url"
"github.com/crewjam/saml"
"github.com/crewjam/saml/samlsp"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/idp"
"github.com/zitadel/zitadel/internal/zerrors"
)
var _ idp.Provider = (*Provider)(nil)
// Provider is the [idp.Provider] implementation for a generic SAML provider
type Provider struct {
name string
requestTracker samlsp.RequestTracker
Certificate []byte
spOptions *samlsp.Options
binding string
nameIDFormat saml.NameIDFormat
transientMappingAttributeName string
isLinkingAllowed bool
isCreationAllowed bool
isAutoCreation bool
isAutoUpdate bool
}
type ProviderOpts func(provider *Provider)
// WithLinkingAllowed allows end users to link the federated user to an existing one.
func WithLinkingAllowed() ProviderOpts {
return func(p *Provider) {
p.isLinkingAllowed = true
}
}
// WithCreationAllowed allows end users to create a new user using the federated information.
func WithCreationAllowed() ProviderOpts {
return func(p *Provider) {
p.isCreationAllowed = true
}
}
// WithAutoCreation enables that federated users are automatically created if not already existing.
func WithAutoCreation() ProviderOpts {
return func(p *Provider) {
p.isAutoCreation = true
}
}
// WithAutoUpdate enables that information retrieved from the provider is automatically used to update
// the existing user on each authentication.
func WithAutoUpdate() ProviderOpts {
return func(p *Provider) {
p.isAutoUpdate = true
}
}
func WithSignedRequest() ProviderOpts {
return func(p *Provider) {
p.spOptions.SignRequest = true
}
}
func WithBinding(binding string) ProviderOpts {
return func(p *Provider) {
p.binding = binding
}
}
func WithNameIDFormat(format domain.SAMLNameIDFormat) ProviderOpts {
return func(p *Provider) {
p.nameIDFormat = nameIDFormatFromDomain(format)
}
}
func WithTransientMappingAttributeName(attribute string) ProviderOpts {
return func(p *Provider) {
p.transientMappingAttributeName = attribute
}
}
func WithCustomRequestTracker(tracker samlsp.RequestTracker) ProviderOpts {
return func(p *Provider) {
p.requestTracker = tracker
}
}
func WithEntityID(entityID string) ProviderOpts {
return func(p *Provider) {
p.spOptions.EntityID = entityID
}
}
func New(
name string,
rootURLStr string,
metadata []byte,
certificate []byte,
key []byte,
options ...ProviderOpts,
) (*Provider, error) {
entityDescriptor := new(saml.EntityDescriptor)
if err := xml.Unmarshal(metadata, entityDescriptor); err != nil {
return nil, err
}
keyPair, err := tls.X509KeyPair(certificate, key)
if err != nil {
return nil, err
}
keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0])
if err != nil {
return nil, err
}
rootURL, err := url.Parse(rootURLStr)
if err != nil {
return nil, err
}
opts := samlsp.Options{
URL: *rootURL,
Key: keyPair.PrivateKey.(*rsa.PrivateKey),
Certificate: keyPair.Leaf,
IDPMetadata: entityDescriptor,
SignRequest: false,
}
provider := &Provider{
name: name,
spOptions: &opts,
Certificate: certificate,
// the library uses transient as default, which does not make sense for federating accounts
nameIDFormat: saml.PersistentNameIDFormat,
}
for _, option := range options {
option(provider)
}
return provider, nil
}
func (p *Provider) Name() string {
return p.name
}
func (p *Provider) IsLinkingAllowed() bool {
return p.isLinkingAllowed
}
func (p *Provider) IsCreationAllowed() bool {
return p.isCreationAllowed
}
func (p *Provider) IsAutoCreation() bool {
return p.isAutoCreation
}
func (p *Provider) IsAutoUpdate() bool {
return p.isAutoUpdate
}
func (p *Provider) GetSP() (*samlsp.Middleware, error) {
sp, err := samlsp.New(*p.spOptions)
if err != nil {
return nil, zerrors.ThrowInternal(err, "SAML-qee09ffuq5", "Errors.Intent.IDPInvalid")
}
sp.ServiceProvider.AuthnNameIDFormat = p.nameIDFormat
if p.requestTracker != nil {
sp.RequestTracker = p.requestTracker
}
if p.binding != "" {
sp.Binding = p.binding
}
return sp, nil
}
func (p *Provider) BeginAuth(ctx context.Context, state string, _ ...idp.Parameter) (idp.Session, error) {
m, err := p.GetSP()
if err != nil {
return nil, err
}
return &Session{
ServiceProvider: m,
state: state,
}, nil
}
func (p *Provider) TransientMappingAttributeName() string {
return p.transientMappingAttributeName
}
func nameIDFormatFromDomain(format domain.SAMLNameIDFormat) saml.NameIDFormat {
switch format {
case domain.SAMLNameIDFormatUnspecified:
return saml.UnspecifiedNameIDFormat
case domain.SAMLNameIDFormatEmailAddress:
return saml.EmailAddressNameIDFormat
case domain.SAMLNameIDFormatPersistent:
return saml.PersistentNameIDFormat
case domain.SAMLNameIDFormatTransient:
return saml.TransientNameIDFormat
default:
return saml.UnspecifiedNameIDFormat
}
}