mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 14:07:31 +00:00
b3b60f0632
With allowIdpInitiated off, the default, every SAML login failed: ProcessResponse passed crewjam/saml no request IDs, and crewjam refuses a Response whose InResponseTo is not among them. Only providers that allowed IdP-initiated logins, which skips the check, could sign in. MakeAuthRequest now records each AuthnRequest ID with the login's returnTo and the hash of a random token that /login sets as the HttpOnly SameSite=Lax cookie pulse_saml_login (__Host- over HTTPS), reused across tabs. The IdP posts its Response cross-site, which withholds Lax cookies, so the ACS answers a cookieless POST with a page that re-posts the same fields once from Pulse's origin. The Response must answer an unexpired request bound to the presenting browser; it spends that request and returns to the recorded path. Another browser's request is left for its owner. The request ID is read from the signature covering the assertion crewjam returns (the signed Response, else that assertion's own SubjectConfirmationData), not from crewjam's parsed fields and not from other signed assertions riding in the same unsigned Response. encoding/xml fills an un-namespaced attribute field from a namespace declaration with the same local name, and exclusive canonicalization drops an unused one from the signed form, so xmlns:InResponseTo could rebind a signed Response and xmlns:IssueInstant / xmlns:NotOnOrAfter let a stale one pass crewjam's age checks: an IdP-initiated login could be replayed indefinitely. The signature verifier now refuses signed content declaring a prefix named like an attribute crewjam reads, in both modes. RelayState is now escaped into the redirect query, so a returnTo with its own query string reaches the IdP whole.
1182 lines
38 KiB
Go
1182 lines
38 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/flate"
|
|
"context"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/beevik/etree"
|
|
"github.com/crewjam/saml"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
|
"github.com/rs/zerolog/log"
|
|
dsig "github.com/russellhaering/goxmldsig"
|
|
)
|
|
|
|
// SAMLService manages SAML Service Provider functionality for a single provider
|
|
type SAMLService struct {
|
|
mu sync.RWMutex
|
|
providerID string
|
|
config *config.SAMLProviderConfig
|
|
sp *saml.ServiceProvider
|
|
idpMetadata *saml.EntityDescriptor
|
|
httpClient *http.Client
|
|
baseURL string
|
|
lastRefresh time.Time
|
|
|
|
// authnRequests holds the SP-initiated AuthnRequests still waiting for
|
|
// the IdP's Response, each bound to the browser that started the login.
|
|
authnRequests samlRequestStore
|
|
// logoutRequests holds the SP-initiated LogoutRequests still waiting for
|
|
// the IdP's LogoutResponse.
|
|
logoutRequests samlRequestStore
|
|
}
|
|
|
|
const (
|
|
// samlRequestTTL bounds how long the IdP round trip of an SP-initiated
|
|
// login or logout may take, matching the OIDC login state lifetime.
|
|
samlRequestTTL = 10 * time.Minute
|
|
maxSAMLRequestEntries = 1024
|
|
// samlLogoutResponseMaxBytes matches crewjam/saml's inflate limit for the
|
|
// HTTP-Redirect binding.
|
|
samlLogoutResponseMaxBytes = 10 << 20
|
|
)
|
|
|
|
// errSAMLLogoutResponseUnbound marks a LogoutResponse that verified but does
|
|
// not answer an outstanding LogoutRequest this SP issued for the presenting
|
|
// session.
|
|
var errSAMLLogoutResponseUnbound = errors.New("logout response does not answer an outstanding logout request for this session")
|
|
|
|
// errSAMLResponseUnbound marks an ACS Response that does not answer an
|
|
// outstanding AuthnRequest this SP issued to the presenting browser.
|
|
var errSAMLResponseUnbound = errors.New("response does not answer an outstanding authentication request from this browser")
|
|
|
|
// samlRequestStore records the requests this SP issued and is still waiting
|
|
// for the IdP to answer, keyed by request ID. Each record carries the key of
|
|
// what the request is bound to: the browser that started an SP-initiated
|
|
// login, or the session an SP-initiated logout ended.
|
|
//
|
|
// crewjam/saml checks a LogoutResponse's signature, destination, issuer,
|
|
// status and age but never its InResponseTo, so without these records any
|
|
// recent signed response from the IdP, including one answering another user's
|
|
// logout, would be accepted. For logins crewjam does check InResponseTo, but
|
|
// only against the request IDs its caller supplies, and without records there
|
|
// were none to supply: every SP-initiated Response was refused.
|
|
type samlRequestStore struct {
|
|
mu sync.Mutex
|
|
entries map[string]samlRequestRecord
|
|
}
|
|
|
|
type samlRequestRecord struct {
|
|
bindingKey string
|
|
// returnTo is the local path an SP-initiated login returns to.
|
|
returnTo string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
func (s *samlRequestStore) put(requestID string, record samlRequestRecord, now time.Time) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.entries == nil {
|
|
s.entries = make(map[string]samlRequestRecord)
|
|
}
|
|
for id, entry := range s.entries {
|
|
if !now.Before(entry.expiresAt) {
|
|
delete(s.entries, id)
|
|
}
|
|
}
|
|
for len(s.entries) >= maxSAMLRequestEntries {
|
|
oldestID := ""
|
|
var oldestExpiry time.Time
|
|
for id, entry := range s.entries {
|
|
if oldestID == "" || entry.expiresAt.Before(oldestExpiry) || (entry.expiresAt.Equal(oldestExpiry) && id < oldestID) {
|
|
oldestID = id
|
|
oldestExpiry = entry.expiresAt
|
|
}
|
|
}
|
|
delete(s.entries, oldestID)
|
|
}
|
|
record.expiresAt = now.Add(samlRequestTTL)
|
|
s.entries[requestID] = record
|
|
}
|
|
|
|
// consume spends the outstanding request a verified LogoutResponse answers.
|
|
// sessionKey identifies the session the presenting browser carries, or is
|
|
// empty when it carries none. The response may complete the logout only when
|
|
// it answers an unexpired request and the browser carries either no session
|
|
// or the session that request logged out. The request is spent on every
|
|
// verified presentation, so a response is honored at most once.
|
|
func (s *samlRequestStore) consume(requestID, sessionKey string, now time.Time) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
entry, ok := s.entries[requestID]
|
|
if requestID == "" || !ok {
|
|
return fmt.Errorf("%w: no outstanding request matches InResponseTo %q", errSAMLLogoutResponseUnbound, requestID)
|
|
}
|
|
delete(s.entries, requestID)
|
|
if !now.Before(entry.expiresAt) {
|
|
return fmt.Errorf("%w: request %q expired", errSAMLLogoutResponseUnbound, requestID)
|
|
}
|
|
if sessionKey != "" && sessionKey != entry.bindingKey {
|
|
return fmt.Errorf("%w: request %q logged out a different session", errSAMLLogoutResponseUnbound, requestID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// outstanding returns every unexpired request, sorted, and whether any of
|
|
// them is bound to bindingKey.
|
|
func (s *samlRequestStore) outstanding(bindingKey string, now time.Time) (requestIDs []string, bound bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for id, entry := range s.entries {
|
|
if now.Before(entry.expiresAt) {
|
|
requestIDs = append(requestIDs, id)
|
|
bound = bound || (bindingKey != "" && entry.bindingKey == bindingKey)
|
|
}
|
|
}
|
|
sort.Strings(requestIDs)
|
|
return requestIDs, bound
|
|
}
|
|
|
|
// spend removes and returns the unexpired request a verified ACS Response
|
|
// answers, provided it is bound to bindingKey, the presenting browser. A
|
|
// request bound to another browser is left for that browser, so presenting a
|
|
// stolen Response elsewhere does not cost its owner the login.
|
|
func (s *samlRequestStore) spend(requestID, bindingKey string, now time.Time) (samlRequestRecord, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
entry, ok := s.entries[requestID]
|
|
if requestID == "" || bindingKey == "" || !ok || entry.bindingKey != bindingKey {
|
|
return samlRequestRecord{}, fmt.Errorf("%w: no outstanding request from this browser matches InResponseTo %q", errSAMLResponseUnbound, requestID)
|
|
}
|
|
delete(s.entries, requestID)
|
|
if !now.Before(entry.expiresAt) {
|
|
return samlRequestRecord{}, fmt.Errorf("%w: request %q expired", errSAMLResponseUnbound, requestID)
|
|
}
|
|
return entry, nil
|
|
}
|
|
|
|
func normalizeSAMLBaseURL(baseURL string) (string, error) {
|
|
normalized := strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
|
if normalized == "" {
|
|
return "", nil
|
|
}
|
|
parsed, err := securityutil.NormalizeAbsoluteHTTPURL(normalized)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimRight(parsed.String(), "/"), nil
|
|
}
|
|
|
|
// SAMLAuthResult contains the result of a successful SAML authentication
|
|
type SAMLAuthResult struct {
|
|
Username string
|
|
Email string
|
|
Groups []string
|
|
FirstName string
|
|
LastName string
|
|
NameID string
|
|
SessionIdx string
|
|
Attributes map[string][]string
|
|
}
|
|
|
|
// NewSAMLService creates a new SAML service for a provider
|
|
func NewSAMLService(ctx context.Context, providerID string, cfg *config.SAMLProviderConfig, baseURL string) (*SAMLService, error) {
|
|
if cfg == nil {
|
|
return nil, errors.New("saml configuration is nil")
|
|
}
|
|
|
|
normalizedBaseURL, err := normalizeSAMLBaseURL(baseURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid saml base url: %w", err)
|
|
}
|
|
|
|
service := &SAMLService{
|
|
providerID: providerID,
|
|
config: cfg,
|
|
baseURL: normalizedBaseURL,
|
|
httpClient: newSAMLHTTPClient(),
|
|
}
|
|
|
|
// Load IdP metadata
|
|
if err := service.loadIDPMetadata(ctx); err != nil {
|
|
return nil, fmt.Errorf("failed to load idp metadata: %w", err)
|
|
}
|
|
|
|
// Initialize Service Provider
|
|
if err := service.initServiceProvider(); err != nil {
|
|
return nil, fmt.Errorf("failed to initialize service provider: %w", err)
|
|
}
|
|
|
|
return service, nil
|
|
}
|
|
|
|
func newSAMLHTTPClient() *http.Client {
|
|
return newSSOHTTPClient(30*time.Second, nil)
|
|
}
|
|
|
|
// loadIDPMetadata loads Identity Provider metadata from URL or XML
|
|
func (s *SAMLService) loadIDPMetadata(ctx context.Context) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
var metadata *saml.EntityDescriptor
|
|
var err error
|
|
|
|
if s.config.IDPMetadataURL != "" {
|
|
metadata, err = s.fetchIDPMetadataFromURL(ctx, s.config.IDPMetadataURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch idp metadata from url: %w", err)
|
|
}
|
|
} else if s.config.IDPMetadataXML != "" {
|
|
metadata, err = parseIDPMetadataXML([]byte(s.config.IDPMetadataXML))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse idp metadata xml: %w", err)
|
|
}
|
|
} else {
|
|
// Build metadata from manual configuration
|
|
metadata, err = s.buildManualMetadata()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to build manual metadata: %w", err)
|
|
}
|
|
}
|
|
|
|
s.idpMetadata = metadata
|
|
s.lastRefresh = time.Now()
|
|
|
|
log.Info().
|
|
Str("provider_id", s.providerID).
|
|
Str("entity_id", metadata.EntityID).
|
|
Msg("Loaded SAML IdP metadata")
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SAMLService) fetchIDPMetadataFromURL(ctx context.Context, metadataURL string) (*saml.EntityDescriptor, error) {
|
|
targetURL, err := validateSSOFetchURL(ctx, metadataURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := securityutil.NewValidatedRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := s.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("metadata request returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MB limit
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return parseIDPMetadataXML(body)
|
|
}
|
|
|
|
func parseIDPMetadataXML(data []byte) (*saml.EntityDescriptor, error) {
|
|
var metadata saml.EntityDescriptor
|
|
if err := xml.Unmarshal(data, &metadata); err != nil {
|
|
// Try parsing as EntityDescriptor wrapped in EntitiesDescriptor
|
|
var entities saml.EntitiesDescriptor
|
|
if err2 := xml.Unmarshal(data, &entities); err2 != nil {
|
|
return nil, fmt.Errorf("failed to parse metadata: %w", err)
|
|
}
|
|
if len(entities.EntityDescriptors) == 0 {
|
|
return nil, errors.New("no entity descriptors found in metadata")
|
|
}
|
|
metadata = entities.EntityDescriptors[0]
|
|
}
|
|
return &metadata, nil
|
|
}
|
|
|
|
func (s *SAMLService) buildManualMetadata() (*saml.EntityDescriptor, error) {
|
|
if s.config.IDPSSOURL == "" {
|
|
return nil, errors.New("idp sso url is required for manual configuration")
|
|
}
|
|
|
|
ssoURL, err := securityutil.NormalizeAbsoluteHTTPURL(s.config.IDPSSOURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid idp sso url: %w", err)
|
|
}
|
|
|
|
entityID := s.config.IDPEntityID
|
|
if entityID == "" {
|
|
entityID = s.config.IDPIssuer
|
|
}
|
|
if entityID == "" {
|
|
entityID = ssoURL.String()
|
|
}
|
|
|
|
metadata := &saml.EntityDescriptor{
|
|
EntityID: entityID,
|
|
IDPSSODescriptors: []saml.IDPSSODescriptor{
|
|
{
|
|
SSODescriptor: saml.SSODescriptor{
|
|
RoleDescriptor: saml.RoleDescriptor{
|
|
ProtocolSupportEnumeration: "urn:oasis:names:tc:SAML:2.0:protocol",
|
|
},
|
|
},
|
|
SingleSignOnServices: []saml.Endpoint{
|
|
{
|
|
Binding: saml.HTTPRedirectBinding,
|
|
Location: ssoURL.String(),
|
|
},
|
|
{
|
|
Binding: saml.HTTPPostBinding,
|
|
Location: ssoURL.String(),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Add SLO endpoint if configured
|
|
if s.config.IDPSLOURL != "" {
|
|
sloURL, err := securityutil.NormalizeAbsoluteHTTPURL(s.config.IDPSLOURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid idp slo url: %w", err)
|
|
}
|
|
metadata.IDPSSODescriptors[0].SingleLogoutServices = []saml.Endpoint{
|
|
{
|
|
Binding: saml.HTTPRedirectBinding,
|
|
Location: sloURL.String(),
|
|
},
|
|
}
|
|
}
|
|
|
|
// Add IdP certificate if provided
|
|
if err := s.addIDPCertificate(metadata); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return metadata, nil
|
|
}
|
|
|
|
func (s *SAMLService) addIDPCertificate(metadata *saml.EntityDescriptor) error {
|
|
var certData []byte
|
|
var err error
|
|
|
|
if s.config.IDPCertFile != "" {
|
|
certData, err = readSSORegularFile(s.config.IDPCertFile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read idp certificate file: %w", err)
|
|
}
|
|
} else if s.config.IDPCertificate != "" {
|
|
certData = []byte(s.config.IDPCertificate)
|
|
} else {
|
|
return nil // No certificate provided
|
|
}
|
|
|
|
// Parse PEM certificate
|
|
block, _ := pem.Decode(certData)
|
|
if block == nil {
|
|
return errors.New("failed to decode idp certificate pem")
|
|
}
|
|
|
|
cert, err := x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse idp certificate: %w", err)
|
|
}
|
|
|
|
if len(metadata.IDPSSODescriptors) > 0 {
|
|
metadata.IDPSSODescriptors[0].KeyDescriptors = []saml.KeyDescriptor{
|
|
{
|
|
Use: "signing",
|
|
KeyInfo: saml.KeyInfo{
|
|
X509Data: saml.X509Data{
|
|
// Base64 DER, as <X509Certificate> carries it in IdP
|
|
// metadata: crewjam/saml base64-decodes this field to
|
|
// verify signatures and cannot read a PEM block.
|
|
X509Certificates: []saml.X509Certificate{
|
|
{Data: base64.StdEncoding.EncodeToString(cert.Raw)},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SAMLService) initServiceProvider() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.initServiceProviderLocked()
|
|
}
|
|
|
|
func (s *SAMLService) initServiceProviderLocked() error {
|
|
// Build SP Entity ID
|
|
spEntityID := s.config.SPEntityID
|
|
if spEntityID == "" {
|
|
spEntityID = fmt.Sprintf("%s/saml/%s", s.baseURL, s.providerID)
|
|
}
|
|
|
|
// Build ACS URL
|
|
acsPath := s.config.SPACSPath
|
|
if acsPath == "" {
|
|
acsPath = fmt.Sprintf("/api/saml/%s/acs", s.providerID)
|
|
}
|
|
acsURL, err := url.Parse(s.baseURL + acsPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse acs url: %w", err)
|
|
}
|
|
|
|
// Build Metadata URL
|
|
metadataPath := s.config.SPMetadataPath
|
|
if metadataPath == "" {
|
|
metadataPath = fmt.Sprintf("/api/saml/%s/metadata", s.providerID)
|
|
}
|
|
metadataURL, err := url.Parse(s.baseURL + metadataPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse metadata url: %w", err)
|
|
}
|
|
|
|
forceAuthn := s.config.ForceAuthn
|
|
|
|
sp := saml.ServiceProvider{
|
|
EntityID: spEntityID,
|
|
AcsURL: *acsURL,
|
|
MetadataURL: *metadataURL,
|
|
IDPMetadata: s.idpMetadata,
|
|
AllowIDPInitiated: s.config.AllowIDPInitiated,
|
|
ForceAuthn: &forceAuthn,
|
|
}
|
|
|
|
// Set SLO URL if the IdP supports it
|
|
if len(s.idpMetadata.IDPSSODescriptors) > 0 &&
|
|
len(s.idpMetadata.IDPSSODescriptors[0].SingleLogoutServices) > 0 {
|
|
sloURL, err := url.Parse(s.baseURL + fmt.Sprintf("/api/saml/%s/slo", s.providerID))
|
|
if err == nil {
|
|
sp.SloURL = *sloURL
|
|
}
|
|
}
|
|
|
|
// Load SP certificate and key if signing is enabled
|
|
if s.config.SignRequests {
|
|
cert, key, err := s.loadSPCredentials()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load sp credentials: %w", err)
|
|
}
|
|
sp.Key = key
|
|
sp.Certificate = cert
|
|
}
|
|
|
|
s.sp = &sp
|
|
|
|
log.Info().
|
|
Str("provider_id", s.providerID).
|
|
Str("entity_id", spEntityID).
|
|
Str("acs_url", acsURL.String()).
|
|
Bool("sign_requests", s.config.SignRequests).
|
|
Msg("Initialized SAML Service Provider")
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SAMLService) SetBaseURL(baseURL string) error {
|
|
normalizedBaseURL, err := normalizeSAMLBaseURL(baseURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid saml base url: %w", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if normalizedBaseURL == s.baseURL {
|
|
return nil
|
|
}
|
|
|
|
previousBaseURL := s.baseURL
|
|
previousSP := s.sp
|
|
|
|
s.baseURL = normalizedBaseURL
|
|
if err := s.initServiceProviderLocked(); err != nil {
|
|
s.baseURL = previousBaseURL
|
|
s.sp = previousSP
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SAMLService) loadSPCredentials() (*x509.Certificate, *rsa.PrivateKey, error) {
|
|
var certData, keyData []byte
|
|
var err error
|
|
|
|
// Load certificate
|
|
if s.config.SPCertFile != "" {
|
|
certData, err = readSSORegularFile(s.config.SPCertFile)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to read sp certificate file: %w", err)
|
|
}
|
|
} else if s.config.SPCertificate != "" {
|
|
certData = []byte(s.config.SPCertificate)
|
|
} else {
|
|
return nil, nil, errors.New("sp certificate is required for signing")
|
|
}
|
|
|
|
// Load private key
|
|
if s.config.SPKeyFile != "" {
|
|
keyData, err = readSSORegularFile(s.config.SPKeyFile)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to read sp private key file: %w", err)
|
|
}
|
|
} else if s.config.SPPrivateKey != "" {
|
|
keyData = []byte(s.config.SPPrivateKey)
|
|
} else {
|
|
return nil, nil, errors.New("sp private key is required for signing")
|
|
}
|
|
|
|
// Parse certificate
|
|
certBlock, _ := pem.Decode(certData)
|
|
if certBlock == nil {
|
|
return nil, nil, errors.New("failed to decode sp certificate pem")
|
|
}
|
|
cert, err := x509.ParseCertificate(certBlock.Bytes)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to parse sp certificate: %w", err)
|
|
}
|
|
|
|
// Parse private key
|
|
keyBlock, _ := pem.Decode(keyData)
|
|
if keyBlock == nil {
|
|
return nil, nil, errors.New("failed to decode sp private key pem")
|
|
}
|
|
|
|
var key *rsa.PrivateKey
|
|
switch keyBlock.Type {
|
|
case "RSA PRIVATE KEY":
|
|
key, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
|
|
case "PRIVATE KEY":
|
|
parsedKey, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to parse pkcs8 private key: %w", err)
|
|
}
|
|
var ok bool
|
|
key, ok = parsedKey.(*rsa.PrivateKey)
|
|
if !ok {
|
|
return nil, nil, errors.New("sp private key is not rsa")
|
|
}
|
|
default:
|
|
return nil, nil, fmt.Errorf("unsupported private key type: %s", keyBlock.Type)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to parse sp private key: %w", err)
|
|
}
|
|
|
|
return cert, key, nil
|
|
}
|
|
|
|
// MakeAuthRequest creates a SAML AuthnRequest and returns the HTTP-Redirect
|
|
// binding URL that sends it to the IdP. It records the request as outstanding
|
|
// for browserKey, the login binding key of the browser starting the login,
|
|
// together with returnTo, so that ProcessResponse accepts the IdP's answer to
|
|
// this request only once, only from that browser, and returns it to returnTo.
|
|
func (s *SAMLService) MakeAuthRequest(returnTo, browserKey string) (string, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if s.sp == nil {
|
|
return "", errors.New("service provider not initialized")
|
|
}
|
|
if browserKey == "" {
|
|
return "", errors.New("authentication request has no browser to bind")
|
|
}
|
|
|
|
if returnTo == "" {
|
|
returnTo = "/"
|
|
}
|
|
if len(s.idpMetadata.IDPSSODescriptors) == 0 ||
|
|
len(s.idpMetadata.IDPSSODescriptors[0].SingleSignOnServices) == 0 {
|
|
return "", errors.New("idp does not support single sign-on")
|
|
}
|
|
|
|
// This is crewjam's MakeRedirectAuthenticationRequest, unrolled to learn
|
|
// the request's ID.
|
|
authnRequest, err := s.sp.MakeAuthenticationRequest(s.sp.GetSSOBindingLocation(saml.HTTPRedirectBinding), saml.HTTPRedirectBinding, saml.HTTPPostBinding)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create auth request: %w", err)
|
|
}
|
|
// Redirect appends RelayState to the query as given, so escape it: a
|
|
// returnTo with its own query string would otherwise split into stray
|
|
// parameters and come back from the IdP truncated.
|
|
redirectURL, err := authnRequest.Redirect(url.QueryEscape(returnTo), s.sp)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create auth request: %w", err)
|
|
}
|
|
|
|
log.Debug().
|
|
Str("provider_id", s.providerID).
|
|
Str("redirect_url", redirectURL.String()).
|
|
Msg("Created SAML AuthnRequest")
|
|
|
|
validatedURL, err := validateSAMLRedirectTarget(redirectURL.String(), s.idpMetadata.IDPSSODescriptors[0].SingleSignOnServices)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to validate auth redirect: %w", err)
|
|
}
|
|
s.authnRequests.put(authnRequest.ID, samlRequestRecord{bindingKey: browserKey, returnTo: returnTo}, time.Now())
|
|
return validatedURL, nil
|
|
}
|
|
|
|
// AllowsIDPInitiated reports whether the provider accepts IdP-initiated
|
|
// (unsolicited) Responses, which answer no AuthnRequest and so cannot be bound
|
|
// to the browser that receives them.
|
|
func (s *SAMLService) AllowsIDPInitiated() bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.sp != nil && s.sp.AllowIDPInitiated
|
|
}
|
|
|
|
// ProcessResponse verifies the IdP's Response posted to the ACS and extracts
|
|
// the user it authenticates, along with the local path the login returns to.
|
|
//
|
|
// Unless the provider allows IdP-initiated logins, the Response must answer
|
|
// an unexpired AuthnRequest from MakeAuthRequest that is bound to browserKey,
|
|
// the login binding key of the presenting browser ("" when it carries none),
|
|
// and it spends that request, so each login is completed at most once and only
|
|
// in the browser that started it. The request ID is read from what the IdP's
|
|
// signatures cover, never from crewjam/saml's parsed fields (see
|
|
// samlSignedContent). The returned path is the one recorded with the request,
|
|
// not the posted RelayState. Binding failures wrap errSAMLResponseUnbound.
|
|
//
|
|
// With IdP-initiated logins allowed, crewjam skips InResponseTo entirely, so a
|
|
// Response answering any request, or none, is accepted in any browser and
|
|
// returns to its posted RelayState, as before request binding existed.
|
|
func (s *SAMLService) ProcessResponse(r *http.Request, browserKey string) (*SAMLAuthResult, string, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if s.sp == nil {
|
|
return nil, "", errors.New("service provider not initialized")
|
|
}
|
|
|
|
// Parse the form to get SAMLResponse and RelayState
|
|
if err := r.ParseForm(); err != nil {
|
|
return nil, "", fmt.Errorf("failed to parse form: %w", err)
|
|
}
|
|
|
|
relayState := r.FormValue("RelayState")
|
|
|
|
// crewjam checks that the Response answers one of possibleRequestIDs,
|
|
// every outstanding AuthnRequest here; spend then decides whether it is
|
|
// this browser's.
|
|
now := time.Now()
|
|
possibleRequestIDs := []string{""}
|
|
if !s.sp.AllowIDPInitiated {
|
|
var bound bool
|
|
possibleRequestIDs, bound = s.authnRequests.outstanding(browserKey, now)
|
|
if !bound {
|
|
return nil, relayState, fmt.Errorf("%w: this browser has no login in progress", errSAMLResponseUnbound)
|
|
}
|
|
}
|
|
|
|
// Parse and validate the SAML assertion on a copy of the service provider
|
|
// whose signature verifier keeps what each verified signature covers.
|
|
signed := &samlSignedContent{}
|
|
sp := *s.sp
|
|
sp.SignatureVerifier = signed
|
|
assertion, err := sp.ParseResponse(r, possibleRequestIDs)
|
|
if err != nil {
|
|
return nil, relayState, fmt.Errorf("failed to validate saml response: %w", err)
|
|
}
|
|
|
|
if !s.sp.AllowIDPInitiated {
|
|
requestID, err := signed.inResponseTo(assertion.ID)
|
|
if err != nil {
|
|
return nil, relayState, fmt.Errorf("%w: %v", errSAMLResponseUnbound, err)
|
|
}
|
|
// Artifact resolution and signature checks take time, so judge
|
|
// the request's expiry now rather than when parsing began.
|
|
request, err := s.authnRequests.spend(requestID, browserKey, time.Now())
|
|
if err != nil {
|
|
return nil, relayState, err
|
|
}
|
|
relayState = request.returnTo
|
|
}
|
|
|
|
// Extract user information from assertion
|
|
result := &SAMLAuthResult{
|
|
Attributes: make(map[string][]string),
|
|
}
|
|
|
|
// Get NameID
|
|
if assertion.Subject != nil && assertion.Subject.NameID != nil {
|
|
result.NameID = assertion.Subject.NameID.Value
|
|
}
|
|
|
|
// Get session index from AuthnStatement
|
|
for _, authnStatement := range assertion.AuthnStatements {
|
|
if authnStatement.SessionIndex != "" {
|
|
result.SessionIdx = authnStatement.SessionIndex
|
|
break
|
|
}
|
|
}
|
|
|
|
// Extract attributes
|
|
for _, statement := range assertion.AttributeStatements {
|
|
for _, attr := range statement.Attributes {
|
|
values := make([]string, 0, len(attr.Values))
|
|
for _, v := range attr.Values {
|
|
values = append(values, v.Value)
|
|
}
|
|
result.Attributes[attr.Name] = values
|
|
|
|
// Also try FriendlyName
|
|
if attr.FriendlyName != "" {
|
|
result.Attributes[attr.FriendlyName] = values
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract specific attributes based on configuration
|
|
result.Username = s.extractAttribute(result.Attributes, s.config.UsernameAttr, result.NameID)
|
|
result.Email = s.extractAttribute(result.Attributes, s.config.EmailAttr, "")
|
|
result.FirstName = s.extractAttribute(result.Attributes, s.config.FirstNameAttr, "")
|
|
result.LastName = s.extractAttribute(result.Attributes, s.config.LastNameAttr, "")
|
|
|
|
// Extract groups
|
|
if s.config.GroupsAttr != "" {
|
|
if groups, ok := result.Attributes[s.config.GroupsAttr]; ok {
|
|
result.Groups = groups
|
|
}
|
|
}
|
|
|
|
log.Info().
|
|
Str("provider_id", s.providerID).
|
|
Str("username", result.Username).
|
|
Str("email", result.Email).
|
|
Int("groups", len(result.Groups)).
|
|
Msg("Processed SAML assertion")
|
|
|
|
return result, relayState, nil
|
|
}
|
|
|
|
func (s *SAMLService) extractAttribute(attrs map[string][]string, attrName, defaultValue string) string {
|
|
if attrName == "" {
|
|
return defaultValue
|
|
}
|
|
if vals, ok := attrs[attrName]; ok && len(vals) > 0 {
|
|
return vals[0]
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
// GetMetadata returns the SP metadata XML
|
|
func (s *SAMLService) GetMetadata() ([]byte, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if s.sp == nil {
|
|
return nil, errors.New("service provider not initialized")
|
|
}
|
|
|
|
metadata := s.sp.Metadata()
|
|
return xml.MarshalIndent(metadata, "", " ")
|
|
}
|
|
|
|
// MakeLogoutRequest creates a SAML LogoutRequest for SLO and records its ID
|
|
// as outstanding for sessionKey, the session being logged out, so that
|
|
// ValidateLogoutResponse accepts only the IdP's answer to this request.
|
|
func (s *SAMLService) MakeLogoutRequest(nameID, sessionIdx, sessionKey string) (string, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if s.sp == nil {
|
|
return "", errors.New("service provider not initialized")
|
|
}
|
|
if sessionKey == "" {
|
|
return "", errors.New("logout request has no session to bind")
|
|
}
|
|
|
|
// Check if IdP supports SLO
|
|
if len(s.idpMetadata.IDPSSODescriptors) == 0 ||
|
|
len(s.idpMetadata.IDPSSODescriptors[0].SingleLogoutServices) == 0 {
|
|
return "", errors.New("idp does not support single logout")
|
|
}
|
|
|
|
sloService := s.idpMetadata.IDPSSODescriptors[0].SingleLogoutServices[0]
|
|
|
|
req, err := s.sp.MakeLogoutRequest(sloService.Location, nameID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create logout request: %w", err)
|
|
}
|
|
|
|
// Build redirect URL
|
|
redirectURL := req.Redirect("")
|
|
|
|
validatedURL, err := validateSAMLRedirectTarget(redirectURL.String(), s.idpMetadata.IDPSSODescriptors[0].SingleLogoutServices)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
s.logoutRequests.put(req.ID, samlRequestRecord{bindingKey: sessionKey}, time.Now())
|
|
return validatedURL, nil
|
|
}
|
|
|
|
// ValidateLogoutResponse verifies an incoming IdP-signed SAML LogoutResponse,
|
|
// checking the XML-DSig signature against the IdP's published certificate as
|
|
// well as the standard LogoutResponse temporal / target invariants, and then
|
|
// binds it to the request it answers: its InResponseTo must name an
|
|
// unexpired LogoutRequest from MakeLogoutRequest, which it spends, and
|
|
// sessionKey (the presenting browser's session, or "" for none) must be empty
|
|
// or the session that request logged out. Returns nil only when all of that
|
|
// holds — anything else (no payload, bad signature, unknown issuer, expired,
|
|
// unsolicited, replayed, or another session's logout) returns an error and
|
|
// the caller MUST NOT mutate any session state on the strength of the
|
|
// request. Binding failures wrap errSAMLLogoutResponseUnbound.
|
|
//
|
|
// Without signature validation the SLO callback endpoint was an
|
|
// unauthenticated force-logout DoS: a cross-origin POST with no payload, or
|
|
// any forged payload, cleared the user's session. Before the binding, any
|
|
// user could replay the IdP's answer to their own logout through a cross-site
|
|
// GET to clear someone else's session.
|
|
func (s *SAMLService) ValidateLogoutResponse(req *http.Request, sessionKey string) error {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if s.sp == nil {
|
|
return errors.New("service provider not initialized")
|
|
}
|
|
|
|
inResponseTo, err := s.verifiedLogoutResponseInResponseTo(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.logoutRequests.consume(inResponseTo, sessionKey, time.Now())
|
|
}
|
|
|
|
// verifiedLogoutResponseInResponseTo selects the SAMLResponse payload the way
|
|
// crewjam/saml's ValidateLogoutResponseRequest does (the HTTP-Redirect query
|
|
// parameter first, then the HTTP-POST form field), has crewjam verify exactly
|
|
// that payload, and reads InResponseTo from the same bytes.
|
|
func (s *SAMLService) verifiedLogoutResponseInResponseTo(req *http.Request) (string, error) {
|
|
var raw []byte
|
|
if data := req.URL.Query().Get("SAMLResponse"); data != "" {
|
|
if err := s.sp.ValidateLogoutResponseRedirect(data); err != nil {
|
|
return "", err
|
|
}
|
|
compressed, err := base64.StdEncoding.DecodeString(data)
|
|
if err != nil {
|
|
return "", fmt.Errorf("decode logout response: %w", err)
|
|
}
|
|
raw, err = io.ReadAll(io.LimitReader(flate.NewReader(bytes.NewReader(compressed)), samlLogoutResponseMaxBytes))
|
|
if err != nil {
|
|
return "", fmt.Errorf("inflate logout response: %w", err)
|
|
}
|
|
} else {
|
|
if err := req.ParseForm(); err != nil {
|
|
return "", fmt.Errorf("parse logout response form: %w", err)
|
|
}
|
|
data := req.PostForm.Get("SAMLResponse")
|
|
if err := s.sp.ValidateLogoutResponseForm(data); err != nil {
|
|
return "", err
|
|
}
|
|
var err error
|
|
raw, err = base64.StdEncoding.DecodeString(data)
|
|
if err != nil {
|
|
return "", fmt.Errorf("decode logout response: %w", err)
|
|
}
|
|
}
|
|
return logoutResponseInResponseTo(raw)
|
|
}
|
|
|
|
// logoutResponseInResponseTo returns the root LogoutResponse's InResponseTo
|
|
// attribute as written without a prefix, the one the IdP's enveloped signature
|
|
// covers, or "" when it has none. The attributes are read by hand, lexically,
|
|
// because encoding/xml would also fill an un-namespaced attribute field from a
|
|
// namespace declaration such as xmlns:InResponseTo="...", which exclusive
|
|
// canonicalization can leave out of the signed form when nothing uses it. crewjam
|
|
// has already checked that the root is a protocol LogoutResponse.
|
|
func logoutResponseInResponseTo(raw []byte) (string, error) {
|
|
decoder := xml.NewDecoder(bytes.NewReader(raw))
|
|
for {
|
|
token, err := decoder.RawToken()
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse logout response: %w", err)
|
|
}
|
|
start, ok := token.(xml.StartElement)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if start.Name.Local != "LogoutResponse" {
|
|
return "", fmt.Errorf("parse logout response: unexpected root element %q", start.Name.Local)
|
|
}
|
|
inResponseTo, found := "", false
|
|
for _, attr := range start.Attr {
|
|
if attr.Name.Space != "" || attr.Name.Local != "InResponseTo" {
|
|
continue
|
|
}
|
|
if found {
|
|
return "", errors.New("parse logout response: duplicate InResponseTo")
|
|
}
|
|
inResponseTo, found = attr.Value, true
|
|
}
|
|
return inResponseTo, nil
|
|
}
|
|
}
|
|
|
|
// samlSignedContent is the signature verifier ProcessResponse gives crewjam/saml
|
|
// for one ACS Response. It verifies each signature exactly as crewjam's default
|
|
// verifier does and keeps the content that signature covers: the canonical form
|
|
// goxmldsig digested, which holds only what the IdP signed.
|
|
//
|
|
// crewjam reads a Response's fields by unmarshalling the posted elements with
|
|
// encoding/xml, which fills an un-namespaced attribute field from any attribute
|
|
// with that local name, whatever its prefix, the last one winning. That
|
|
// includes a namespace declaration such as xmlns:IssueInstant, and exclusive
|
|
// canonicalization leaves a declaration nothing uses out of the signed form,
|
|
// so one can be appended to a signed element without breaking its signature
|
|
// and override what crewjam checks: InResponseTo, IssueInstant, NotOnOrAfter,
|
|
// Recipient, Destination. VerifySignature therefore refuses signed content
|
|
// carrying any prefixed attribute or declaration named like an un-namespaced
|
|
// attribute crewjam reads, so crewjam's fields match what was signed, and the
|
|
// binding decision still reads InResponseTo from the kept canonical content
|
|
// rather than from crewjam's fields.
|
|
type samlSignedContent struct {
|
|
elements []*etree.Element
|
|
}
|
|
|
|
func (c *samlSignedContent) VerifySignature(validationContext *dsig.ValidationContext, el *etree.Element) error {
|
|
if shadow := samlShadowingAttribute(el); shadow != "" {
|
|
return fmt.Errorf("cannot validate signature on %s: %s shadows a SAML attribute", el.Tag, shadow)
|
|
}
|
|
signed, err := validationContext.Validate(el)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot validate signature on %s: %v", el.Tag, err)
|
|
}
|
|
c.elements = append(c.elements, signed)
|
|
return nil
|
|
}
|
|
|
|
// inResponseTo returns the AuthnRequest ID that the signature covering the
|
|
// assertion crewjam returned, identified by its ID, says it answers.
|
|
//
|
|
// When the Response, or an ArtifactResponse carrying it, is signed, the IdP
|
|
// signed the whole message, the returned assertion included, and the signed
|
|
// Response's InResponseTo names the request. Otherwise crewjam verified the
|
|
// returned assertion's own signature, and the request is named by that
|
|
// assertion's SubjectConfirmationData: every SubjectConfirmation of its
|
|
// Subject must carry the same one, and at least one must exist, because
|
|
// crewjam accepts a Subject without any. Other signed assertions in the same
|
|
// Response, and assertions in Advice, do not take part.
|
|
func (c *samlSignedContent) inResponseTo(assertionID string) (string, error) {
|
|
for _, el := range c.elements {
|
|
response := el
|
|
if el.Tag == "ArtifactResponse" {
|
|
response = samlChildElement(el, "Response")
|
|
}
|
|
if response == nil || response.Tag != "Response" {
|
|
continue
|
|
}
|
|
if requestID := samlUnprefixedAttr(response, "InResponseTo"); requestID != "" {
|
|
return requestID, nil
|
|
}
|
|
return "", errors.New("the signed Response answers no request")
|
|
}
|
|
var assertion *etree.Element
|
|
for _, el := range c.elements {
|
|
if el.Tag != "Assertion" || samlUnprefixedAttr(el, "ID") != assertionID {
|
|
continue
|
|
}
|
|
if assertion != nil {
|
|
return "", fmt.Errorf("several signed assertions carry ID %q", assertionID)
|
|
}
|
|
assertion = el
|
|
}
|
|
if assertion == nil {
|
|
return "", errors.New("no signature covers the returned assertion")
|
|
}
|
|
requestID := ""
|
|
if subject := samlChildElement(assertion, "Subject"); subject != nil {
|
|
for _, confirmation := range subject.ChildElements() {
|
|
if confirmation.Tag != "SubjectConfirmation" {
|
|
continue
|
|
}
|
|
// crewjam keeps the last SubjectConfirmationData of each
|
|
// confirmation, so every one must agree.
|
|
named := false
|
|
for _, data := range confirmation.ChildElements() {
|
|
if data.Tag != "SubjectConfirmationData" {
|
|
continue
|
|
}
|
|
value := samlUnprefixedAttr(data, "InResponseTo")
|
|
if value == "" || (requestID != "" && value != requestID) {
|
|
return "", errors.New("the signed assertion's subject confirmations do not name one request")
|
|
}
|
|
requestID, named = value, true
|
|
}
|
|
if !named {
|
|
return "", errors.New("a signed subject confirmation names no request")
|
|
}
|
|
}
|
|
}
|
|
if requestID == "" {
|
|
return "", errors.New("the signed assertion has no subject confirmation naming a request")
|
|
}
|
|
return requestID, nil
|
|
}
|
|
|
|
// samlChildElement returns el's first child element with local name tag.
|
|
func samlChildElement(el *etree.Element, tag string) *etree.Element {
|
|
for _, child := range el.ChildElements() {
|
|
if child.Tag == tag {
|
|
return child
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// samlUnprefixedAttr returns el's attribute key written without a prefix, the
|
|
// one a signature over el covers, or "" when it has none.
|
|
func samlUnprefixedAttr(el *etree.Element, key string) string {
|
|
for _, attr := range el.Attr {
|
|
if attr.Space == "" && attr.Key == key {
|
|
return attr.Value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// samlBoundAttributeNames holds the name of every un-namespaced XML attribute
|
|
// crewjam/saml unmarshals from a Response or an Assertion. Namespaced ones such
|
|
// as xsi:type only match their own namespace.
|
|
var samlBoundAttributeNames = func() map[string]bool {
|
|
names := make(map[string]bool)
|
|
seen := make(map[reflect.Type]bool)
|
|
var collect func(t reflect.Type)
|
|
collect = func(t reflect.Type) {
|
|
for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice || t.Kind() == reflect.Array {
|
|
t = t.Elem()
|
|
}
|
|
if t.Kind() != reflect.Struct || seen[t] {
|
|
return
|
|
}
|
|
seen[t] = true
|
|
for i := 0; i < t.NumField(); i++ {
|
|
field := t.Field(i)
|
|
name, options, _ := strings.Cut(field.Tag.Get("xml"), ",")
|
|
if strings.Contains(","+options+",", ",attr,") {
|
|
if name == "" {
|
|
name = field.Name
|
|
}
|
|
if !strings.Contains(name, " ") {
|
|
names[name] = true
|
|
}
|
|
}
|
|
collect(field.Type)
|
|
}
|
|
}
|
|
collect(reflect.TypeOf(saml.Response{}))
|
|
collect(reflect.TypeOf(saml.Assertion{}))
|
|
return names
|
|
}()
|
|
|
|
// samlShadowingAttribute returns the first prefixed attribute or namespace
|
|
// declaration in el or its descendants whose local name is also an
|
|
// un-namespaced attribute crewjam/saml reads, as written, or "" when there is
|
|
// none.
|
|
func samlShadowingAttribute(el *etree.Element) string {
|
|
for _, attr := range el.Attr {
|
|
if attr.Space != "" && samlBoundAttributeNames[attr.Key] {
|
|
return attr.Space + ":" + attr.Key
|
|
}
|
|
}
|
|
for _, child := range el.ChildElements() {
|
|
if shadow := samlShadowingAttribute(child); shadow != "" {
|
|
return shadow
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func validateSAMLRedirectTarget(rawURL string, allowedEndpoints []saml.Endpoint) (string, error) {
|
|
validatedURL, err := securityutil.NormalizeAbsoluteHTTPURL(rawURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, endpoint := range allowedEndpoints {
|
|
endpointURL, err := securityutil.NormalizeAbsoluteHTTPURL(endpoint.Location)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if strings.EqualFold(validatedURL.Scheme, endpointURL.Scheme) &&
|
|
strings.EqualFold(validatedURL.Host, endpointURL.Host) &&
|
|
validatedURL.Path == endpointURL.Path {
|
|
return validatedURL.String(), nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("redirect target does not match configured SAML endpoint")
|
|
}
|
|
|
|
// RefreshMetadata reloads IdP metadata (useful for key rotation)
|
|
func (s *SAMLService) RefreshMetadata(ctx context.Context) error {
|
|
if s.config.IDPMetadataURL == "" {
|
|
return errors.New("cannot refresh metadata without url")
|
|
}
|
|
|
|
if err := s.loadIDPMetadata(ctx); err != nil {
|
|
return fmt.Errorf("load idp metadata: %w", err)
|
|
}
|
|
|
|
// Reinitialize SP with new metadata
|
|
if err := s.initServiceProvider(); err != nil {
|
|
return fmt.Errorf("initialize service provider: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ProviderID returns the provider identifier
|
|
func (s *SAMLService) ProviderID() string {
|
|
return s.providerID
|
|
}
|
|
|
|
// GetSPEntityID returns the Service Provider Entity ID
|
|
func (s *SAMLService) GetSPEntityID() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if s.sp == nil {
|
|
return ""
|
|
}
|
|
return s.sp.EntityID
|
|
}
|
|
|
|
// GetIDPEntityID returns the Identity Provider Entity ID
|
|
func (s *SAMLService) GetIDPEntityID() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if s.idpMetadata == nil {
|
|
return ""
|
|
}
|
|
return s.idpMetadata.EntityID
|
|
}
|