Files
teleport/lib/services/saml.go
T

336 lines
12 KiB
Go

/*
* Teleport
* Copyright (C) 2023 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package services
import (
"context"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/xml"
"net/http"
"strings"
"time"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
saml2 "github.com/russellhaering/gosaml2"
samltypes "github.com/russellhaering/gosaml2/types"
dsig "github.com/russellhaering/goxmldsig"
log "github.com/sirupsen/logrus"
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/tlsca"
"github.com/gravitational/teleport/lib/utils"
)
// ValidateSAMLConnector validates the SAMLConnector and sets default values.
// If a remote to fetch roles is specified, roles will be validated to exist.
func ValidateSAMLConnector(sc types.SAMLConnector, rg RoleGetter) error {
if err := CheckAndSetDefaults(sc); err != nil {
return trace.Wrap(err)
}
if sc.GetEntityDescriptorURL() != "" {
resp, err := http.Get(sc.GetEntityDescriptorURL())
if err != nil {
return trace.WrapWithMessage(err, "unable to fetch entity descriptor from %v for SAML connector %v", sc.GetEntityDescriptorURL(), sc.GetName())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return trace.BadParameter("status code %v when fetching from %v for SAML connector %v", resp.StatusCode, sc.GetEntityDescriptorURL(), sc.GetName())
}
body, err := utils.ReadAtMost(resp.Body, teleport.MaxHTTPResponseSize)
if err != nil {
return trace.Wrap(err)
}
sc.SetEntityDescriptor(string(body))
log.Debugf("[SAML] Successfully fetched entity descriptor from %v for connector %v", sc.GetEntityDescriptorURL(), sc.GetName())
}
if sc.GetEntityDescriptor() != "" {
metadata := &samltypes.EntityDescriptor{}
if err := xml.Unmarshal([]byte(sc.GetEntityDescriptor()), metadata); err != nil {
return trace.Wrap(err, "failed to parse entity_descriptor")
}
sc.SetIssuer(metadata.EntityID)
if metadata.IDPSSODescriptor != nil && len(metadata.IDPSSODescriptor.SingleSignOnServices) > 0 {
sc.SetSSO(metadata.IDPSSODescriptor.SingleSignOnServices[0].Location)
}
}
if sc.GetIssuer() == "" {
return trace.BadParameter("no issuer or entityID set, either set issuer as a parameter or via entity_descriptor spec")
}
if sc.GetSSO() == "" {
return trace.BadParameter("no SSO set either explicitly or via entity_descriptor spec")
}
if sc.GetSigningKeyPair() == nil {
keyPEM, certPEM, err := utils.GenerateSelfSignedSigningCert(pkix.Name{
Organization: []string{"Teleport OSS"},
CommonName: "teleport.localhost.localdomain",
}, nil, 10*365*24*time.Hour)
if err != nil {
return trace.Wrap(err)
}
sc.SetSigningKeyPair(&types.AsymmetricKeyPair{
PrivateKey: string(keyPEM),
Cert: string(certPEM),
})
}
if len(sc.GetAttributesToRoles()) == 0 {
return trace.BadParameter("attributes_to_roles is empty, authorization with connector would never assign any roles")
}
if rg != nil {
for _, mapping := range sc.GetAttributesToRoles() {
for _, role := range mapping.Roles {
if utils.ContainsExpansion(role) {
// Role is a template so we cannot check for existence of that literal name.
continue
}
_, err := rg.GetRole(context.Background(), role)
switch {
case trace.IsNotFound(err):
return trace.BadParameter("role %q specified in attributes_to_roles not found", role)
case err != nil:
return trace.Wrap(err)
}
}
}
}
log.Debugf("[SAML] SSO: %v", sc.GetSSO())
log.Debugf("[SAML] Issuer: %v", sc.GetIssuer())
log.Debugf("[SAML] ACS: %v", sc.GetAssertionConsumerService())
return nil
}
// GetAttributeNames returns a list of claim names from the claim values
func GetAttributeNames(attributes map[string]samltypes.Attribute) []string {
var out []string
for _, attr := range attributes {
out = append(out, attr.Name)
}
return out
}
// SAMLAssertionsToTraits converts saml assertions to traits
func SAMLAssertionsToTraits(assertions saml2.AssertionInfo) map[string][]string {
traits := make(map[string][]string, len(assertions.Values))
for _, assr := range assertions.Values {
vals := make([]string, 0, len(assr.Values))
for _, value := range assr.Values {
vals = append(vals, value.Value)
}
traits[assr.Name] = vals
}
return traits
}
// CheckSAMLEntityDescriptor checks if the entity descriptor XML is valid and has at least one valid certificate.
func CheckSAMLEntityDescriptor(entityDescriptor string) ([]*x509.Certificate, error) {
if entityDescriptor == "" {
return nil, nil
}
metadata := &samltypes.EntityDescriptor{}
if err := xml.Unmarshal([]byte(entityDescriptor), metadata); err != nil {
return nil, trace.Wrap(err, "failed to parse entity_descriptor")
}
var roots []*x509.Certificate
for _, kd := range metadata.IDPSSODescriptor.KeyDescriptors {
for _, samlCert := range kd.KeyInfo.X509Data.X509Certificates {
// The certificate is base64 encoded and can be split into multiple lines.
// Each line can be padded with spaces/tabs, so we need to remove them first
// before decoding otherwise we'll get an error.
// We need to run this through strings.Fields to remove spaces/tabs
// from each line and then join them back with newlines.
// The last step isn't strictly necessary, but it makes payload more readable.
certData, err := base64.StdEncoding.DecodeString(strings.Join(strings.Fields(samlCert.Data), "\n"))
if err != nil {
return nil, trace.Wrap(err, "failed to decode certificate defined in entity_descriptor")
}
cert, err := x509.ParseCertificate(certData)
if err != nil {
return nil, trace.Wrap(err, "failed to parse certificate defined in entity_descriptor")
}
roots = append(roots, cert)
}
}
return roots, nil
}
// GetSAMLServiceProvider gets the SAMLConnector's service provider
func GetSAMLServiceProvider(sc types.SAMLConnector, clock clockwork.Clock) (*saml2.SAMLServiceProvider, error) {
roots, errEd := CheckSAMLEntityDescriptor(sc.GetEntityDescriptor())
if errEd != nil {
return nil, trace.Wrap(errEd)
}
certStore := dsig.MemoryX509CertificateStore{Roots: roots}
if sc.GetCert() != "" {
cert, err := tlsca.ParseCertificatePEM([]byte(sc.GetCert()))
if err != nil {
return nil, trace.Wrap(err, "failed to parse certificate defined in cert")
}
certStore.Roots = append(certStore.Roots, cert)
}
if len(certStore.Roots) == 0 {
return nil, trace.BadParameter("no identity provider certificate provided, either set certificate as a parameter or via entity_descriptor")
}
signingKeyPair := sc.GetSigningKeyPair()
encryptionKeyPair := sc.GetEncryptionKeyPair()
var keyStore *utils.KeyStore
var signingKeyStore *utils.KeyStore
var err error
// Due to some weird design choices with how gosaml2 keys are configured we have to do some trickery
// in order to default properly when SAML assertion encryption is turned off.
// Below are the different possible cases.
if encryptionKeyPair == nil {
// Case 1: Only the signing key pair is set. This means that SAML encryption is not expected
// and we therefore configure the main key that gets used for all operations as the signing key.
// This is done because gosaml2 mandates an encryption key even if not used.
log.Info("No assertion_key_pair was detected. Falling back to signing key for all SAML operations.")
keyStore, err = utils.ParseKeyStorePEM(signingKeyPair.PrivateKey, signingKeyPair.Cert)
signingKeyStore = keyStore
if err != nil {
return nil, trace.Wrap(err, "failed to parse certificate or private key defined in signing_key_pair")
}
} else {
// Case 2: An encryption keypair is configured. This means that encrypted SAML responses are expected.
// Since gosaml2 always uses the main key for encryption, we set it to assertion_key_pair.
// To handle signing correctly, we now instead set the optional signing key in gosaml2 to signing_key_pair.
log.Info("Detected assertion_key_pair and configured it to decrypt SAML responses.")
keyStore, err = utils.ParseKeyStorePEM(encryptionKeyPair.PrivateKey, encryptionKeyPair.Cert)
if err != nil {
return nil, trace.Wrap(err, "failed to parse certificate or private key defined in assertion_key_pair")
}
signingKeyStore, err = utils.ParseKeyStorePEM(signingKeyPair.PrivateKey, signingKeyPair.Cert)
if err != nil {
return nil, trace.Wrap(err, "failed to parse certificate or private key defined in signing_key_pair")
}
}
sp := &saml2.SAMLServiceProvider{
IdentityProviderSSOURL: sc.GetSSO(),
IdentityProviderIssuer: sc.GetIssuer(),
ServiceProviderIssuer: sc.GetServiceProviderIssuer(),
AssertionConsumerServiceURL: sc.GetAssertionConsumerService(),
SignAuthnRequests: true,
SignAuthnRequestsCanonicalizer: dsig.MakeC14N11Canonicalizer(),
AudienceURI: sc.GetAudience(),
IDPCertificateStore: &certStore,
SPSigningKeyStore: signingKeyStore,
SPKeyStore: keyStore,
Clock: dsig.NewFakeClock(clock),
NameIdFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
}
// Provider specific settings for ADFS and JumpCloud. Specifically these
// providers do not support C14N11, which means a C14N10 canonicalizer has to
// be used.
switch sc.GetProvider() {
case teleport.ADFS, teleport.JumpCloud:
log.WithFields(log.Fields{
teleport.ComponentKey: teleport.ComponentSAML,
}).Debug("Setting ADFS/JumpCloud values.")
if sp.SignAuthnRequests {
sp.SignAuthnRequestsCanonicalizer = dsig.MakeC14N10ExclusiveCanonicalizerWithPrefixList(dsig.DefaultPrefix)
// At a minimum we require password protected transport.
sp.RequestedAuthnContext = &saml2.RequestedAuthnContext{
Comparison: "minimum",
Contexts: []string{"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"},
}
}
}
return sp, nil
}
// UnmarshalSAMLConnector unmarshals the SAMLConnector resource from JSON.
func UnmarshalSAMLConnector(bytes []byte, opts ...MarshalOption) (types.SAMLConnector, error) {
cfg, err := CollectOptions(opts)
if err != nil {
return nil, trace.Wrap(err)
}
var h types.ResourceHeader
err = utils.FastUnmarshal(bytes, &h)
if err != nil {
return nil, trace.Wrap(err)
}
switch h.Version {
case types.V2:
var c types.SAMLConnectorV2
if err := utils.FastUnmarshal(bytes, &c); err != nil {
return nil, trace.BadParameter(err.Error())
}
if err := ValidateSAMLConnector(&c, nil); err != nil {
return nil, trace.Wrap(err)
}
if cfg.ID != 0 {
c.SetResourceID(cfg.ID)
}
if cfg.Revision != "" {
c.SetRevision(cfg.Revision)
}
if !cfg.Expires.IsZero() {
c.SetExpiry(cfg.Expires)
}
return &c, nil
}
return nil, trace.BadParameter("SAML connector resource version %v is not supported", h.Version)
}
// MarshalSAMLConnector marshals the SAMLConnector resource to JSON.
func MarshalSAMLConnector(samlConnector types.SAMLConnector, opts ...MarshalOption) ([]byte, error) {
if err := ValidateSAMLConnector(samlConnector, nil); err != nil {
return nil, trace.Wrap(err)
}
cfg, err := CollectOptions(opts)
if err != nil {
return nil, trace.Wrap(err)
}
switch samlConnector := samlConnector.(type) {
case *types.SAMLConnectorV2:
return utils.FastMarshal(maybeResetProtoResourceID(cfg.PreserveResourceID, samlConnector))
default:
return nil, trace.BadParameter("unrecognized SAML connector version %T", samlConnector)
}
}