Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -0,0 +1,139 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package authorities
import (
"crypto"
"errors"
"fmt"
"net/http"
"net/url"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/oidc-go"
)
// Details hold immutable information about external authorities identified by ID.
type Details struct {
ID string
Name string
AuthorityType string
ClientID string
ClientSecret string
Trusted bool
Insecure bool
Scopes []string
ResponseType string
ResponseMode string
CodeChallengeMethod string
EndSessionEnabled bool
registration AuthorityRegistration
ready bool
validationKeys map[string]crypto.PublicKey
}
// IsReady returns wether or not the associated registration entry was ready
// at time of creation of the associated details.
func (d *Details) IsReady() bool {
return d.ready
}
// IdentityClaimValue returns the identity claim value from the provided data.
func (d *Details) IdentityClaimValue(claims interface{}) (string, map[string]interface{}, error) {
return d.registration.IdentityClaimValue(claims)
}
// MakeRedirectAuthenticationRequestURL returns the authentication request
// URL which can be used to initiate authentication with the associated
// authority. It takes a state as parameter and in addition to the URL it also
// returns a mapping of extra state data and potentially an error.
func (d *Details) MakeRedirectAuthenticationRequestURL(state string) (*url.URL, map[string]interface{}, error) {
return d.registration.MakeRedirectAuthenticationRequestURL(state)
}
// MakeRedirectEndSessionRequestURL returns the end session request URL which
// can be used to initiate end session with the associated authority. It takes
// a state as paraeter and in addition to the URL it also returns a mappting
// of extra state data and potentially an error.
func (d *Details) MakeRedirectEndSessionRequestURL(ref interface{}, state string) (*url.URL, map[string]interface{}, error) {
return d.registration.MakeRedirectEndSessionRequestURL(ref, state)
}
// MakeRedirectEndSessionResponseURL returns the end session response URL which
// can be used to redirect back the response for an incoming end session request.
// It takes the authority specific request and a state, returning the destination
// url, additional state mapping and potential error.
func (d *Details) MakeRedirectEndSessionResponseURL(req interface{}, state string) (*url.URL, map[string]interface{}, error) {
return d.registration.MakeRedirectEndSessionResponseURL(req, state)
}
// ParseStateResponse takes an incoming request, a state and optional extra data
// and returns the parsed authority specific response data for that request or
// error.
func (d *Details) ParseStateResponse(req *http.Request, state string, extra map[string]interface{}) (interface{}, error) {
return d.registration.ParseStateResponse(req, state, extra)
}
// JWTKeyfunc returns a key func to validate JWTs with the keys of the associated
// authority registration.
func (d *Details) JWTKeyfunc() jwt.Keyfunc {
return d.validateJWT
}
func (d *Details) validateJWT(token *jwt.Token) (interface{}, error) {
rawAlg, ok := token.Header[oidc.JWTHeaderAlg]
if !ok {
return nil, errors.New("no alg header")
}
alg, ok := rawAlg.(string)
if !ok {
return nil, errors.New("invalid alg value")
}
switch jwt.GetSigningMethod(alg).(type) {
case *jwt.SigningMethodRSA:
case *jwt.SigningMethodECDSA:
case *jwt.SigningMethodRSAPSS:
default:
return nil, fmt.Errorf("unexpected alg value")
}
rawKid, ok := token.Header[oidc.JWTHeaderKeyID]
if !ok {
return nil, fmt.Errorf("no kid header")
}
kid, ok := rawKid.(string)
if !ok {
return nil, fmt.Errorf("invalid kid value")
}
if key, ok := d.validationKeys[kid]; ok {
return key, nil
}
return nil, errors.New("no key available")
}
func (d *Details) Metadata() interface{} {
return d.registration.Metadata()
}
+103
View File
@@ -0,0 +1,103 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package authorities
import (
"context"
"net/http"
"net/url"
"github.com/go-jose/go-jose/v3"
)
// Supported Authority kind string values.
const (
AuthorityTypeOIDC = "oidc"
AuthorityTypeSAML2 = "saml2"
)
type authorityRegistrationData struct {
ID string `json:"id"`
Name string `json:"name"`
AuthorityType string `json:"authority_type"`
Iss string `json:"iss"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
EntityID string `json:"entity_id"`
Trusted bool `json:"trusted"`
Insecure bool `json:"insecure"`
Default bool `json:"default"`
Discover *bool `json:"discover"`
Scopes []string `json:"scopes"`
ResponseType string `json:"response_type"`
ResponseMode string `json:"response_mode"`
CodeChallengeMethod string `json:"code_challenge_method"`
RawMetadataEndpoint string `json:"metadata_endpoint"`
RawAuthorizationEndpoint string `json:"authorization_endpoint"`
RawTokenEndpoint string `json:"token_endpoint"`
UserInfoEndpoint string `json:"user_info_endpoint"`
JWKS *jose.JSONWebKeySet `json:"jwks"`
IdentityClaimName string `json:"identity_claim_name"`
IdentityAliases map[string]string `json:"identity_aliases"`
IdentityAliasRequired bool `json:"identity_alias_required"`
EndSessionEnabled bool `json:"end_session_enabled"`
}
type authorityRegistryData struct {
Authorities []*authorityRegistrationData `json:"authorities"`
}
// AuthorityRegistration defines an authority with its properties.
type AuthorityRegistration interface {
ID() string
Name() string
AuthorityType() string
Authority() *Details
Issuer() string
Validate() error
Initialize(ctx context.Context, registry *Registry) error
MakeRedirectAuthenticationRequestURL(state string) (*url.URL, map[string]interface{}, error)
MakeRedirectEndSessionRequestURL(ref interface{}, state string) (*url.URL, map[string]interface{}, error)
MakeRedirectEndSessionResponseURL(req interface{}, state string) (*url.URL, map[string]interface{}, error)
ParseStateResponse(req *http.Request, state string, extra map[string]interface{}) (interface{}, error)
ValidateIdpEndSessionRequest(req interface{}, state string) (bool, error)
ValidateIdpEndSessionResponse(res interface{}, state string) (bool, error)
IdentityClaimValue(data interface{}) (string, map[string]interface{}, error)
Metadata() AuthorityMetadata
}
type AuthorityMetadata interface {
}
+510
View File
@@ -0,0 +1,510 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package authorities
import (
"context"
"crypto"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"github.com/go-jose/go-jose/v3"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/oidc-go"
"github.com/sirupsen/logrus"
konnectoidc "github.com/libregraph/lico/oidc"
"github.com/libregraph/lico/oidc/payload"
"github.com/libregraph/lico/utils"
)
// Authority default values.
var (
oidcAuthorityDefaultScopes = []string{oidc.ScopeOpenID, oidc.ScopeProfile}
oidcAuthorityDefaultResponseType = oidc.ResponseTypeCode
oidcAuthorityDefaultResponseMode = oidc.ResponseModeQuery
oidcAuthorityDefaultCodeChallengeMethod = oidc.S256CodeChallengeMethod
oidcAuthorityDefaultIdentityClaimName = oidc.PreferredUsernameClaim
)
type oidcAuthorityRegistration struct {
registry *Registry
data *authorityRegistrationData
discover bool
metadataEndpoint *url.URL
authorizationEndpoint *url.URL
tokenEndpoint *url.URL
endSessionEndpoint *url.URL
userInfoEndpoint *url.URL
validationKeys map[string]crypto.PublicKey
mutex sync.RWMutex
ready bool
wellKnown *oidc.WellKnown
}
func newOIDCAuthorityRegistration(registry *Registry, registrationData *authorityRegistrationData) (*oidcAuthorityRegistration, error) {
ar := &oidcAuthorityRegistration{
registry: registry,
data: registrationData,
}
if ar.data.RawMetadataEndpoint != "" {
if u, err := url.Parse(ar.data.RawMetadataEndpoint); err == nil {
ar.metadataEndpoint = u
} else {
return nil, fmt.Errorf("invalid metadata_endpoint value: %v", err)
}
}
if ar.data.RawAuthorizationEndpoint != "" {
if u, err := url.Parse(ar.data.RawAuthorizationEndpoint); err == nil {
if u.Scheme != "https" {
return nil, errors.New("authorization_endpoint must be https")
}
ar.authorizationEndpoint = u
} else {
return nil, fmt.Errorf("invalid authorization_endpoint value: %v", err)
}
}
if ar.data.RawTokenEndpoint != "" {
if u, err := url.Parse(ar.data.RawTokenEndpoint); err == nil {
if u.Scheme != "https" {
return nil, errors.New("token_endpoint must be https")
}
ar.tokenEndpoint = u
} else {
return nil, fmt.Errorf("invalid token_endpoint value: %v", err)
}
}
if ar.data.UserInfoEndpoint != "" {
if u, err := url.Parse(ar.data.UserInfoEndpoint); err == nil {
if u.Scheme != "https" {
return nil, errors.New("userinfo_endpoint must be https")
}
ar.userInfoEndpoint = u
} else {
return nil, fmt.Errorf("invalid userinfo_endpoint value: %v", err)
}
}
if ar.data.JWKS != nil {
if err := ar.setValidationKeysFromJWKS(ar.data.JWKS, false); err != nil {
return nil, err
}
}
if ar.data.Discover != nil {
ar.discover = *ar.data.Discover
}
// Additional behavior.
if ar.metadataEndpoint == nil && (ar.data.Discover == nil || ar.discover == true) {
if ar.data.Iss == "" {
return nil, fmt.Errorf("oidc authority iss is empty")
}
if issuer, err := url.Parse(ar.data.Iss); err == nil {
relativeWellKnownURI, parseErr := url.Parse(strings.TrimRight(issuer.Path, "/") + "/.well-known/openid-configuration")
if parseErr != nil {
return nil, parseErr
}
ar.metadataEndpoint = issuer.ResolveReference(relativeWellKnownURI)
ar.discover = true
} else {
return nil, fmt.Errorf("invalid iss value: %v", err)
}
}
if !ar.discover {
if ar.authorizationEndpoint == nil {
return nil, errors.New("authorization_endpoint is empty")
}
if ar.data.JWKS == nil && !ar.data.Insecure {
return nil, errors.New("jwks is empty")
}
}
return ar, nil
}
func (ar *oidcAuthorityRegistration) ID() string {
return ar.data.ID
}
func (ar *oidcAuthorityRegistration) Name() string {
return ar.data.Name
}
func (ar *oidcAuthorityRegistration) AuthorityType() string {
return ar.data.AuthorityType
}
func (ar *oidcAuthorityRegistration) Authority() *Details {
details := &Details{
ID: ar.data.ID,
Name: ar.data.Name,
AuthorityType: ar.data.AuthorityType,
ClientID: ar.data.ClientID,
ClientSecret: ar.data.ClientSecret,
Trusted: ar.data.Trusted,
Insecure: ar.data.Insecure,
Scopes: ar.data.Scopes,
ResponseType: ar.data.ResponseType,
ResponseMode: ar.data.ResponseMode,
CodeChallengeMethod: ar.data.CodeChallengeMethod,
EndSessionEnabled: ar.data.EndSessionEnabled,
registration: ar,
}
ar.mutex.RLock()
details.ready = ar.ready
if ar.ready {
details.validationKeys = ar.validationKeys
}
ar.mutex.RUnlock()
return details
}
func (ar *oidcAuthorityRegistration) Issuer() string {
return ar.data.Iss
}
func (ar *oidcAuthorityRegistration) setValidationKeysFromJWKS(jwks *jose.JSONWebKeySet, skipInvalid bool) error {
if jwks == nil || len(jwks.Keys) == 0 {
ar.validationKeys = nil
return nil
}
ar.validationKeys = make(map[string]crypto.PublicKey)
skipped := 0
for _, jwk := range jwks.Keys {
if jwk.Use == "sig" {
if key, ok := jwk.Key.(crypto.PublicKey); ok {
ar.validationKeys[jwk.KeyID] = key
} else {
if !skipInvalid {
return fmt.Errorf("failed to decode public key")
} else {
skipped++
}
}
}
}
if skipped > 0 {
return fmt.Errorf("failed to decode %d keys in set", skipped)
}
return nil
}
func (ar *oidcAuthorityRegistration) Validate() error {
if ar.data.ClientID == "" {
return errors.New("invalid authority client_id")
}
// Ensure some defaults.
if len(ar.data.Scopes) == 0 {
ar.data.Scopes = oidcAuthorityDefaultScopes
}
if ar.data.ResponseType == "" {
ar.data.ResponseType = oidcAuthorityDefaultResponseType
}
if ar.data.ResponseMode == "" {
ar.data.ResponseMode = oidcAuthorityDefaultResponseMode
}
if ar.data.CodeChallengeMethod == "" {
ar.data.CodeChallengeMethod = oidcAuthorityDefaultCodeChallengeMethod
}
if ar.data.IdentityClaimName == "" {
ar.data.IdentityClaimName = oidcAuthorityDefaultIdentityClaimName
}
return nil
}
func (ar *oidcAuthorityRegistration) Initialize(ctx context.Context, registry *Registry) error {
ar.mutex.Lock()
defer ar.mutex.Unlock()
if ar.authorizationEndpoint != nil && ar.validationKeys != nil {
ar.ready = true
}
if ar.metadataEndpoint == nil {
return fmt.Errorf("no metadata_endpoint set")
}
return initializeOIDC(ctx, registry.logger, ar)
}
func (ar *oidcAuthorityRegistration) IdentityClaimValue(rawToken interface{}) (string, map[string]interface{}, error) {
idToken, _ := rawToken.(*jwt.Token)
if idToken == nil {
return "", nil, errors.New("invalid ID token data")
}
claims, _ := idToken.Claims.(jwt.MapClaims)
if claims == nil {
return "", nil, errors.New("invalid claims data")
}
icn := ar.data.IdentityClaimName
if icn == "" {
icn = oidc.PreferredUsernameClaim
}
cvr, ok := claims[icn]
if !ok {
return "", nil, errors.New("identity claim not found")
}
cvs, ok := cvr.(string)
if !ok {
return "", nil, errors.New("identify claim has invalid type")
}
// Add extra external authority claims, for example SessionIndex.
extra := make(map[string]interface{})
extra["RawIDToken"] = idToken.Raw
// Convert claim value.
whitelisted := false
if ar.data.IdentityAliases != nil {
if alias, ok := ar.data.IdentityAliases[cvs]; ok && alias != "" {
cvs = alias
whitelisted = true
}
}
// Check whitelist.
if ar.data.IdentityAliasRequired && !whitelisted {
return "", nil, errors.New("identity claim has no alias")
}
return cvs, extra, nil
}
func (ar *oidcAuthorityRegistration) MakeRedirectAuthenticationRequestURL(state string) (*url.URL, map[string]interface{}, error) {
ar.mutex.RLock()
defer ar.mutex.RUnlock()
if !ar.ready {
return nil, nil, errors.New("not ready")
}
uri, _ := url.Parse(ar.authorizationEndpoint.String())
query := make(url.Values)
query.Add("state", state)
uri.RawQuery = query.Encode()
return uri, nil, nil
}
func (ar *oidcAuthorityRegistration) MakeRedirectEndSessionRequestURL(ref interface{}, state string) (*url.URL, map[string]interface{}, error) {
ar.mutex.RLock()
defer ar.mutex.RUnlock()
if !ar.ready {
return nil, nil, errors.New("not ready")
}
logonRef := ref.(*string)
if logonRef == nil {
// Do nothing when we cannot provide id token hint.
return nil, nil, nil
}
uri, _ := url.Parse(ar.endSessionEndpoint.String())
query := make(url.Values)
query.Add("state", state)
query.Add("id_token_hint", *logonRef)
uri.RawQuery = query.Encode()
return uri, nil, nil
}
func (ar *oidcAuthorityRegistration) MakeRedirectEndSessionResponseURL(req interface{}, state string) (*url.URL, map[string]interface{}, error) {
return nil, nil, fmt.Errorf("idp end session not implemented")
}
func (ar *oidcAuthorityRegistration) ParseStateResponse(req *http.Request, state string, extra map[string]interface{}) (interface{}, error) {
if authenticationErrorID := req.Form.Get("error"); authenticationErrorID != "" {
// Incoming error case.
return nil, konnectoidc.NewOAuth2Error(authenticationErrorID, req.Form.Get("error_description"))
}
// Success case.
authenticationSuccess := &payload.AuthenticationSuccess{}
err := utils.DecodeURLSchema(authenticationSuccess, req.Form)
if err != nil {
return nil, fmt.Errorf("failed to parse oidc state response: %w", err)
}
return authenticationSuccess, nil
}
func (ar *oidcAuthorityRegistration) ValidateIdpEndSessionRequest(req interface{}, state string) (bool, error) {
return false, fmt.Errorf("not implemented")
}
func (ar *oidcAuthorityRegistration) ValidateIdpEndSessionResponse(res interface{}, state string) (bool, error) {
return false, fmt.Errorf("not implemented")
}
func (ar *oidcAuthorityRegistration) Metadata() AuthorityMetadata {
ar.mutex.RLock()
defer ar.mutex.RUnlock()
return &oidc.WellKnown{
Issuer: ar.data.Iss,
AuthorizationEndpoint: ar.authorizationEndpoint.String(),
TokenEndpoint: ar.tokenEndpoint.String(),
UserInfoEndpoint: ar.userInfoEndpoint.String(),
EndSessionEndpoint: ar.endSessionEndpoint.String(),
}
}
type oidcProviderLogger struct {
logger logrus.FieldLogger
}
func (logger *oidcProviderLogger) Printf(format string, args ...interface{}) {
logger.logger.Debugf(format, args...)
}
func initializeOIDC(ctx context.Context, logger logrus.FieldLogger, ar *oidcAuthorityRegistration) error {
providerLogger := logger.WithFields(logrus.Fields{
"id": ar.data.ID,
"type": AuthorityTypeOIDC,
})
config := &oidc.ProviderConfig{
Logger: &oidcProviderLogger{providerLogger},
HTTPHeader: http.Header{},
}
if ar.data.Insecure {
config.HTTPClient = utils.InsecureHTTPClient
} else {
config.HTTPClient = utils.DefaultHTTPClient
}
config.HTTPHeader.Set("User-Agent", utils.DefaultHTTPUserAgent)
issuer, err := url.Parse(ar.data.Iss)
if err != nil {
return fmt.Errorf("failed to parse issuer: %v", err)
}
if issuer.Scheme != "https" {
return fmt.Errorf("issuer scheme is not https")
}
if issuer.Host == "" {
return fmt.Errorf("issuer host is empty")
}
provider, err := oidc.NewProvider(issuer, config)
if err != nil {
return fmt.Errorf("failed to create oidc provider: %v", err)
}
updateCh := make(chan *oidc.ProviderDefinition)
errorCh := make(chan error)
err = provider.Initialize(ctx, updateCh, errorCh)
if err != nil {
return fmt.Errorf("failed to initialize oidc provider: %v", err)
}
go func() {
// Handle updates and errors of authority meta data.
var pd *oidc.ProviderDefinition
var jwks *jose.JSONWebKeySet
for {
pd = nil
select {
case <-ctx.Done():
return
case update := <-updateCh:
pd = update
case chErr := <-errorCh:
providerLogger.Errorf("error while oidc provider update: %v", chErr)
}
if pd != nil {
ar.mutex.Lock()
if pd.WellKnown != nil && pd.WellKnown.AuthorizationEndpoint != "" {
if ar.authorizationEndpoint, err = url.Parse(pd.WellKnown.AuthorizationEndpoint); err != nil {
providerLogger.WithError(err).Errorln("failed to parse oidc provider discover document authorization_endpoint")
}
}
if pd.WellKnown != nil && pd.WellKnown.EndSessionEndpoint != "" {
if ar.endSessionEndpoint, err = url.Parse(pd.WellKnown.EndSessionEndpoint); err != nil {
providerLogger.WithError(err).Errorln("failed to parse oidc provider discover document endsession_endpoint")
}
}
if pd.WellKnown != nil && pd.WellKnown.TokenEndpoint != "" {
if ar.tokenEndpoint, err = url.Parse(pd.WellKnown.TokenEndpoint); err != nil {
providerLogger.WithError(err).Errorln("failed to parse oidc provider discover document token_endpoint")
}
}
if pd.WellKnown != nil && pd.WellKnown.UserInfoEndpoint != "" {
if ar.userInfoEndpoint, err = url.Parse(pd.WellKnown.UserInfoEndpoint); err != nil {
providerLogger.WithError(err).Errorln("failed to parse oidc provider discover document userinfo_endpoint")
}
}
if pd.JWKS != jwks {
if err := ar.setValidationKeysFromJWKS(pd.JWKS, true); err != nil {
providerLogger.Errorf("failed to set authority keys from oidc provider jwks: %v", err)
}
}
if pd.WellKnown != nil {
ar.wellKnown = pd.WellKnown
}
ready := ar.ready
if ar.authorizationEndpoint != nil && ar.validationKeys != nil {
ar.ready = true
} else {
ar.ready = false
}
if ready != ar.ready {
if ar.ready {
providerLogger.Infoln("authority is now ready")
} else {
providerLogger.Warnln("authority is no longer ready")
}
} else if !ar.ready {
providerLogger.Warnln("authority not ready")
}
ar.mutex.Unlock()
}
}
}()
return nil
}
@@ -0,0 +1,225 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package authorities
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/url"
"sync"
"github.com/sirupsen/logrus"
"sigs.k8s.io/yaml"
)
// Registry implements the registry for registered authorities.
type Registry struct {
mutex sync.RWMutex
baseURI *url.URL
defaultID string
authorities map[string]AuthorityRegistration
logger logrus.FieldLogger
}
// NewRegistry creates a new authorizations Registry with the provided parameters.
func NewRegistry(ctx context.Context, baseURI *url.URL, registrationConfFilepath string, logger logrus.FieldLogger) (*Registry, error) {
registryData := &authorityRegistryData{}
if registrationConfFilepath != "" {
logger.Debugf("parsing authorities registration conf from %v", registrationConfFilepath)
registryFile, err := ioutil.ReadFile(registrationConfFilepath)
if err != nil {
return nil, err
}
// NOTE(longsleep): Convert YAML to JSON for parsing. This is done to be
// able to use jose.JSONWebKeySet custom JSON unmarshaller which is not
// available for YAML. We could use sigs.k8s.io/yaml directly as it has
// the same behavior, but we do it explicitly to be clear.
j, err := yaml.YAMLToJSON(registryFile)
if err != nil {
return nil, err
}
err = json.Unmarshal(j, registryData)
if err != nil {
return nil, err
}
}
r := &Registry{
baseURI: baseURI,
authorities: make(map[string]AuthorityRegistration),
logger: logger,
}
var defaultAuthorityRegistrationData *authorityRegistrationData
var defaultAuthority AuthorityRegistration
for _, registrationData := range registryData.Authorities {
var authority AuthorityRegistration
var validateErr error
if registrationData.ID == "" {
registrationData.ID = registrationData.Name
r.logger.WithField("id", registrationData.ID).Warnln("authority has no id, using name")
}
switch registrationData.AuthorityType {
case AuthorityTypeOIDC:
authority, validateErr = newOIDCAuthorityRegistration(r, registrationData)
case AuthorityTypeSAML2:
authority, validateErr = newSAML2AuthorityRegistration(r, registrationData)
}
fields := logrus.Fields{
"id": registrationData.ID,
"authority_type": registrationData.AuthorityType,
"insecure": registrationData.Insecure,
"trusted": registrationData.Trusted,
"default": registrationData.Default,
"alias_required": registrationData.IdentityAliasRequired,
}
if validateErr != nil {
logger.WithError(validateErr).WithFields(fields).Warnln("skipped registration of invalid authority entry")
continue
}
if authority == nil {
logger.WithFields(fields).Warnln("skipped registration of authority of unknown type")
continue
}
if registerErr := r.Register(authority); registerErr != nil {
logger.WithError(registerErr).WithFields(fields).Warnln("skipped registration of invalid authority")
continue
}
if registrationData.Default || defaultAuthorityRegistrationData == nil {
if defaultAuthorityRegistrationData == nil || !defaultAuthorityRegistrationData.Default {
defaultAuthorityRegistrationData = registrationData
defaultAuthority = authority
} else {
logger.Warnln("ignored default authority flag since already have a default")
}
} else {
// TODO(longsleep): Implement authority selection.
logger.Warnln("non-default additional authorities are not supported yet")
}
go func() {
if initializeErr := authority.Initialize(ctx, r); initializeErr != nil {
logger.WithError(initializeErr).WithFields(fields).Warnln("failed to initialize authority")
}
}()
logger.WithFields(fields).Debugln("registered authority")
}
if defaultAuthority != nil {
if defaultAuthorityRegistrationData.Default {
r.defaultID = defaultAuthorityRegistrationData.ID
logger.WithField("id", defaultAuthorityRegistrationData.ID).Infoln("using external default authority")
} else {
logger.Warnln("non-default authorities are not supported yet")
}
}
return r, nil
}
// Register validates the provided authority registration and adds the authority
// to the accociated registry if valid. Returns error otherwise.
func (r *Registry) Register(authority AuthorityRegistration) error {
id := authority.ID()
if id == "" {
return errors.New("no authority id")
}
if err := authority.Validate(); err != nil {
return fmt.Errorf("authority data validation error: %w", err)
}
switch authority.AuthorityType() {
case AuthorityTypeOIDC:
// breaks
case AuthorityTypeSAML2:
// breaks
default:
return fmt.Errorf("unknown authority type: %v", authority.AuthorityType())
}
r.mutex.Lock()
defer r.mutex.Unlock()
r.authorities[id] = authority
return nil
}
// Lookup returns and validates the authority Detail information for the provided
// parameters from the accociated authority registry.
func (r *Registry) Lookup(ctx context.Context, authorityID string) (*Details, error) {
registration, ok := r.Get(ctx, authorityID)
if !ok {
return nil, fmt.Errorf("unknown authority id: %v", authorityID)
}
details := registration.Authority()
return details, nil
}
// Get returns the registered authorities registration for the provided client ID.
func (r *Registry) Get(ctx context.Context, authorityID string) (AuthorityRegistration, bool) {
if authorityID == "" {
return nil, false
}
// Lookup authority registration.
r.mutex.RLock()
registration, ok := r.authorities[authorityID]
r.mutex.RUnlock()
return registration, ok
}
// Find returns the first registered authority that satisfies the provided
// selector function.
func (r *Registry) Find(ctx context.Context, selector func(authority AuthorityRegistration) bool) (AuthorityRegistration, bool) {
r.mutex.RLock()
defer r.mutex.RUnlock()
for _, authority := range r.authorities {
if selector(authority) {
return authority, true
}
}
return nil, false
}
// Default returns the default authority from the associated registry if any.
func (r *Registry) Default(ctx context.Context) *Details {
authority, _ := r.Lookup(ctx, r.defaultID)
return authority
}
+650
View File
@@ -0,0 +1,650 @@
/*
* Copyright 2017-2020 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package authorities
import (
"context"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/crewjam/httperr"
"github.com/crewjam/saml"
"github.com/crewjam/saml/samlsp"
dsig "github.com/russellhaering/goxmldsig"
"github.com/sirupsen/logrus"
"github.com/libregraph/lico/identity/authorities/samlext"
"github.com/libregraph/lico/utils"
)
var cleanWhitespaceRegexp = regexp.MustCompile(`\s+`)
type saml2AuthorityRegistration struct {
registry *Registry
data *authorityRegistrationData
discover bool
metadataEndpoint *url.URL
mutex sync.RWMutex
ready bool
serviceProvider *saml.ServiceProvider
serviceProviderSigningCerts []*x509.Certificate
}
func newSAML2AuthorityRegistration(registry *Registry, registrationData *authorityRegistrationData) (*saml2AuthorityRegistration, error) {
ar := &saml2AuthorityRegistration{
registry: registry,
data: registrationData,
}
if ar.data.RawMetadataEndpoint != "" {
if u, err := url.Parse(ar.data.RawMetadataEndpoint); err == nil {
ar.metadataEndpoint = u
} else {
return nil, fmt.Errorf("invalid metadata_endpoint value: %w", err)
}
}
if ar.data.EntityID == "" {
baseURIString := registry.baseURI.String()
metadataURI, _ := url.Parse(baseURIString + "/identifier/saml2/metadata") // Use our own meta data
ar.data.EntityID = metadataURI.String()
}
if ar.data.Discover != nil {
ar.discover = *ar.data.Discover
}
if !ar.discover {
return nil, errors.New("saml2 must use discover")
}
return ar, nil
}
func (ar *saml2AuthorityRegistration) ID() string {
return ar.data.ID
}
func (ar *saml2AuthorityRegistration) Name() string {
return ar.data.Name
}
func (ar *saml2AuthorityRegistration) AuthorityType() string {
return ar.data.AuthorityType
}
func (ar *saml2AuthorityRegistration) Authority() *Details {
details := &Details{
ID: ar.data.ID,
Name: ar.data.Name,
AuthorityType: ar.data.AuthorityType,
Trusted: ar.data.Trusted,
Insecure: ar.data.Insecure,
EndSessionEnabled: ar.data.EndSessionEnabled,
registration: ar,
}
ar.mutex.RLock()
details.ready = ar.ready
ar.mutex.RUnlock()
return details
}
func (ar *saml2AuthorityRegistration) Issuer() string {
issuer := ar.serviceProvider.IDPMetadata.EntityID
if issuer == "" {
issuer = ar.metadataEndpoint.String()
}
return issuer
}
func (ar *saml2AuthorityRegistration) Validate() error {
return nil
}
func (ar *saml2AuthorityRegistration) Initialize(ctx context.Context, registry *Registry) error {
ar.mutex.Lock()
defer ar.mutex.Unlock()
if ar.metadataEndpoint == nil {
return fmt.Errorf("no metadata_endpoint set")
}
if ar.data.EntityID == "" {
return fmt.Errorf("no entity_id set")
}
logger := registry.logger.WithFields(logrus.Fields{
"id": ar.data.ID,
"type": AuthorityTypeSAML2,
})
var client *http.Client
if ar.data.Insecure {
client = utils.InsecureHTTPClient
} else {
client = utils.DefaultHTTPClient
}
baseURIString := registry.baseURI.String()
acsURL, _ := url.Parse(baseURIString + "/identifier/saml2/acs") // Assertion Consumer Service
sloURL, _ := url.Parse(baseURIString + "/identifier/_/saml2/slo") // Single Logout Service
go func() {
var md *saml.EntityDescriptor
var err error
for {
logger.Debugf("fetching SAML2 provider meta data: %s", ar.metadataEndpoint.String())
md, err = func() (*saml.EntityDescriptor, error) {
req, fetchErr := http.NewRequest(http.MethodGet, ar.metadataEndpoint.String(), nil)
if fetchErr != nil {
return nil, fetchErr
}
req = req.WithContext(ctx)
req.Header.Set("User-Agent", utils.DefaultHTTPUserAgent)
resp, fetchErr := client.Do(req)
if fetchErr != nil {
return nil, fetchErr
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, httperr.Response(*resp)
}
data, fetchErr := ioutil.ReadAll(resp.Body)
if fetchErr != nil {
return nil, fetchErr
}
return samlsp.ParseMetadata(data)
}()
if err != nil {
logger.WithError(err).Errorln("error while saml2 provider meta data update")
}
select {
case <-ctx.Done():
return
default:
}
if md != nil {
for {
var serviceProviderSigningCerts []*x509.Certificate
serviceProviderSigningCerts, err = getCertsFromMetadata(md, "signing")
if err != nil {
break
}
if len(serviceProviderSigningCerts) == 0 {
err = errors.New("no signing certificate in meta data")
break
}
ar.mutex.Lock()
ar.serviceProviderSigningCerts = serviceProviderSigningCerts
ar.serviceProvider = &saml.ServiceProvider{
EntityID: ar.data.EntityID,
AcsURL: *acsURL,
SloURL: *sloURL,
IDPMetadata: md,
AllowIDPInitiated: false,
AuthnNameIDFormat: saml.TransientNameIDFormat,
}
ready := ar.ready
if ar.serviceProvider != nil {
ar.ready = true
} else {
ar.ready = false
}
if ready != ar.ready {
if ar.ready {
logger.Infoln("authority is now ready")
} else {
logger.Warnln("authority is no longer ready")
}
} else if !ar.ready {
logger.Warnln("authority not ready")
}
ready = ar.ready
ar.mutex.Unlock()
if ready {
logger.WithFields(logrus.Fields{
"signing_certs": len(serviceProviderSigningCerts),
"issuer": ar.Issuer(),
}).Debugln("SAML2 provider meta data loaded and initialized")
return
}
break
}
if err != nil {
logger.WithError(err).Errorln("error while initializing saml2 provider from meta data")
}
}
select {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
// breaks
}
}
}()
return nil
}
func (ar *saml2AuthorityRegistration) IdentityClaimValue(rawAssertion interface{}) (string, map[string]interface{}, error) {
assertion, _ := rawAssertion.(*saml.Assertion)
if assertion == nil {
return "", nil, errors.New("invalid assertion data")
}
icn := ar.data.IdentityClaimName
if icn == "" {
icn = "uid" // TODO(longsleep): Use constant.
}
var cvs string
var ok bool
for _, attributeStatement := range assertion.AttributeStatements {
for _, attr := range attributeStatement.Attributes {
values := []string{}
for _, value := range attr.Values {
values = append(values, value.Value)
}
ar.registry.logger.WithFields(logrus.Fields{
"FriendlyName": attr.FriendlyName,
"Name": attr.Name,
"NameFormat": attr.NameFormat,
"Values": values,
}).Debugln("saml2 attributeStatement")
if !ok {
claimName := attr.FriendlyName
if claimName == "" {
claimName = attr.Name
}
if claimName == icn && len(values) == 1 {
if attr.NameFormat != "urn:oasis:names:tc:SAML:2.0:attrname-format:basic" {
ar.registry.logger.WithField("NameFormat", attr.NameFormat).Warnln("saml2 ignoring unsupported name format for identity claim name")
continue
}
cvs = values[0]
ok = true
}
}
}
}
if !ok {
return "", nil, errors.New("identity claim not found")
}
// Add extra external authority claims, for example SessionIndex.
claims := make(map[string]interface{})
for _, authnStatement := range assertion.AuthnStatements {
ar.registry.logger.WithFields(logrus.Fields{
"SessionNotOnOrAfter": authnStatement.SessionNotOnOrAfter,
"SessionIndex": authnStatement.SessionIndex,
}).Debugln("saml2 authnStatement")
if authnStatement.SessionIndex != "" {
claims["SessionIndex"] = authnStatement.SessionIndex
if authnStatement.SessionNotOnOrAfter != nil {
if saml.TimeNow().After(*authnStatement.SessionNotOnOrAfter) {
return "", nil, errors.New("session is expired")
}
claims["SessionNotOnOrAfter"] = authnStatement.SessionNotOnOrAfter
}
}
}
if assertion.Subject != nil {
switch assertion.Subject.NameID.Format {
case string(saml.TransientNameIDFormat):
claims["TransientNameID"] = assertion.Subject.NameID.Value
case string(saml.PersistentNameIDFormat):
claims["PersistentNameID"] = assertion.Subject.NameID.Value
case string(saml.UnspecifiedNameIDFormat):
claims["UnspecifiedNameID"] = assertion.Subject.NameID.Value
default:
return "", nil, errors.New("nameid format must be transient")
}
} else {
return "", nil, errors.New("subject not found")
}
// Convert claim value.
whitelisted := false
if ar.data.IdentityAliases != nil {
if alias, ok := ar.data.IdentityAliases[cvs]; ok && alias != "" {
cvs = alias
whitelisted = true
}
}
// Check whitelist.
if ar.data.IdentityAliasRequired && !whitelisted {
return "", nil, errors.New("identity claim has no alias")
}
return cvs, claims, nil
}
func (ar *saml2AuthorityRegistration) MakeRedirectAuthenticationRequestURL(state string) (*url.URL, map[string]interface{}, error) {
ar.mutex.RLock()
defer ar.mutex.RUnlock()
if !ar.ready {
return nil, nil, errors.New("not ready")
}
authReq, err := ar.serviceProvider.MakeAuthenticationRequest(ar.serviceProvider.GetSSOBindingLocation(saml.HTTPRedirectBinding), saml.HTTPRedirectBinding, saml.HTTPPostBinding)
if err != nil {
return nil, nil, err
}
uri, err := authReq.Redirect(state, ar.serviceProvider)
if err != nil {
return nil, nil, err
}
return uri, map[string]interface{}{
"rid": authReq.ID,
}, nil
}
func (ar *saml2AuthorityRegistration) MakeRedirectEndSessionRequestURL(ref interface{}, state string) (*url.URL, map[string]interface{}, error) {
ar.mutex.RLock()
defer ar.mutex.RUnlock()
if !ar.ready {
return nil, nil, errors.New("not ready")
}
logonRef := ref.(*string)
var nameID string
var nameIDFormat saml.NameIDFormat
if logonRef != nil {
logonRefParts := strings.SplitN(*logonRef, ":", 2)
switch logonRefParts[0] {
case "transient":
nameIDFormat = saml.TransientNameIDFormat
case "persistent":
nameIDFormat = saml.PersistentNameIDFormat
case "unspecified":
nameIDFormat = saml.UnspecifiedNameIDFormat
default:
return nil, nil, fmt.Errorf("unsupported name id format prefix: %v", logonRefParts[0])
}
nameID = logonRefParts[1]
}
req, err := ar.serviceProvider.MakeLogoutRequest(ar.serviceProvider.GetSLOBindingLocation(saml.HTTPRedirectBinding), nameID)
if err != nil {
return nil, nil, fmt.Errorf("failed to make redirect logout request: %w", err)
}
req.NameID.Format = string(nameIDFormat)
lor := &samlext.LogoutRequest{
LogoutRequest: req,
}
return lor.Redirect(state), nil, nil
}
func (ar *saml2AuthorityRegistration) MakeRedirectEndSessionResponseURL(rawReq interface{}, state string) (*url.URL, map[string]interface{}, error) {
ar.mutex.RLock()
defer ar.mutex.RUnlock()
if !ar.ready {
return nil, nil, errors.New("not ready")
}
req, _ := rawReq.(*saml.LogoutRequest)
if req == nil {
return nil, nil, errors.New("invalid request data")
}
// NOTE(longsleep): This resonse currently always reports success.
status := &saml.Status{
StatusCode: saml.StatusCode{
Value: saml.StatusSuccess,
},
}
res, err := samlext.MakeLogoutResponse(ar.serviceProvider, req, status, saml.HTTPRedirectBinding)
if err != nil {
return nil, nil, fmt.Errorf("failed to make logout response: %w", err)
}
uri := res.Redirect(state)
return uri, nil, nil
}
func (ar *saml2AuthorityRegistration) ParseStateResponse(req *http.Request, state string, extra map[string]interface{}) (interface{}, error) {
requestID := extra["rid"].(string)
return ar.serviceProvider.ParseResponse(req, []string{requestID})
}
func (ar *saml2AuthorityRegistration) ValidateIdpEndSessionRequest(req interface{}, state string) (bool, error) {
slo := req.(*samlext.IdpLogoutRequest)
if slo.Request == nil {
return false, fmt.Errorf("request not set")
}
// NOTE(longsleep): We currently only support redirect binding (which uses a detached signature).
if slo.Binding != saml.HTTPRedirectBinding {
return false, fmt.Errorf("binding not supported")
}
// Only validate signature if signed.
if slo.SigAlg == nil {
return false, nil
}
ar.mutex.RLock()
serviceProviderSigningCerts := ar.serviceProviderSigningCerts
ready := ar.ready
ar.mutex.RUnlock()
if !ready {
return false, errors.New("not ready")
}
if len(serviceProviderSigningCerts) == 0 {
// No signing certs, cannot do anything.
return false, nil
}
// Check if we are good.
switch *slo.SigAlg {
case dsig.RSASHA1SignatureMethod:
ar.registry.logger.WithField("sig_alg", *slo.SigAlg).Warnln("saml2 insecure signature alg in idp logout request")
if !ar.Authority().Insecure {
return false, nil
}
default:
// Let the rest pass, and decide later.
}
if len(slo.Signature) == 0 {
return true, fmt.Errorf("signature data is empty")
}
// Get first certificate, and verify.
if len(serviceProviderSigningCerts) > 1 {
ar.registry.logger.Warnln("saml2 authority has multiple signing keys, using first")
}
pubKey := serviceProviderSigningCerts[0].PublicKey
if verifyErr := slo.VerifySignature(pubKey); verifyErr != nil {
return true, fmt.Errorf("signature verification failed: %w", verifyErr)
}
return true, nil
}
func (ar *saml2AuthorityRegistration) ValidateIdpEndSessionResponse(res interface{}, state string) (bool, error) {
lor := res.(*samlext.IdpLogoutResponse)
if lor.Response == nil {
return false, fmt.Errorf("response not set")
}
// NOTE(longsleep): We currently only support redirect binding (which uses a detached signature).
if lor.Binding != saml.HTTPRedirectBinding {
return false, fmt.Errorf("binding not supported")
}
// Only validate signature if signed.
if lor.SigAlg == nil {
return false, nil
}
ar.mutex.RLock()
serviceProviderSigningCerts := ar.serviceProviderSigningCerts
ready := ar.ready
ar.mutex.RUnlock()
if !ready {
return false, errors.New("not ready")
}
if len(serviceProviderSigningCerts) == 0 {
// No signing certs, cannot do anything.
return false, nil
}
// Check if we are good.
switch *lor.SigAlg {
case dsig.RSASHA1SignatureMethod:
ar.registry.logger.WithField("sig_alg", *lor.SigAlg).Warnln("saml2 insecure signature alg in idp logout response")
if !ar.Authority().Insecure {
return false, nil
}
default:
// Let the rest pass, and decide later.
}
if len(lor.Signature) == 0 {
return true, fmt.Errorf("signature data is empty")
}
// Get first certificate, and verify.
if len(serviceProviderSigningCerts) > 1 {
ar.registry.logger.Warnln("saml2 authority has multiple signing keys, using first")
}
pubKey := serviceProviderSigningCerts[0].PublicKey
if verifyErr := lor.VerifySignature(pubKey); verifyErr != nil {
return true, fmt.Errorf("signature verification failed: %w", verifyErr)
}
return true, nil
}
func (ar *saml2AuthorityRegistration) Metadata() AuthorityMetadata {
ar.mutex.RLock()
sp := ar.serviceProvider
ar.mutex.RUnlock()
if sp == nil {
return nil
}
metadata := sp.Metadata()
// Set SLO to use redirect binding.
metadata.SPSSODescriptors[0].SSODescriptor.SingleLogoutServices = []saml.Endpoint{
{
Binding: saml.HTTPRedirectBinding,
Location: sp.SloURL.String(),
},
}
return metadata
}
func getCertsFromMetadata(md *saml.EntityDescriptor, use string) ([]*x509.Certificate, error) {
var certStrs []string
for _, idpSSODescriptor := range md.IDPSSODescriptors {
for _, keyDescriptor := range idpSSODescriptor.KeyDescriptors {
if keyDescriptor.Use == use {
for _, cert := range keyDescriptor.KeyInfo.X509Data.X509Certificates {
if cert.Data != "" {
certStrs = append(certStrs, cert.Data)
}
}
}
}
}
// If there are no explicitly signing certs, just return the first non-empty cert we find.
if len(certStrs) == 0 {
for _, idpSSODescriptor := range md.IDPSSODescriptors {
for _, keyDescriptor := range idpSSODescriptor.KeyDescriptors {
if keyDescriptor.Use == "" {
for _, cert := range keyDescriptor.KeyInfo.X509Data.X509Certificates {
if cert.Data != "" {
certStrs = append(certStrs, cert.Data)
}
}
break
}
}
}
}
var certs []*x509.Certificate
for _, certStr := range certStrs {
certStr = cleanWhitespaceRegexp.ReplaceAllString(certStr, "")
certBytes, err := base64.StdEncoding.DecodeString(certStr)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
parsedCert, err := x509.ParseCertificate(certBytes)
if err != nil {
return nil, err
}
certs = append(certs, parsedCert)
}
return certs, nil
}
@@ -0,0 +1,108 @@
/*
* Copyright 2020 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package samlext
import (
"crypto"
"crypto/rsa"
_ "crypto/sha1" // Import all supported hashers.
_ "crypto/sha256"
_ "crypto/sha512"
"fmt"
"strings"
dsig "github.com/russellhaering/goxmldsig"
)
// VerifySignedHTTPRedirectQuery implements validation for signed SAML HTTP
// redirect binding parameters provides via URL query.
func VerifySignedHTTPRedirectQuery(kind string, rawQuery string, sigAlg string, signature []byte, pubKey crypto.PublicKey) error {
var hasher crypto.Hash
// Validate signature.
switch sigAlg {
case dsig.RSASHA1SignatureMethod:
hasher = crypto.SHA1
case dsig.RSASHA256SignatureMethod:
hasher = crypto.SHA256
case dsig.RSASHA512SignatureMethod:
hasher = crypto.SHA512
default:
return fmt.Errorf("unsupported sig alg: %v", sigAlg)
}
if len(signature) == 0 {
return fmt.Errorf("signature data is empty")
}
// The signed data format goes like this:
// SAMLRequest=urlencode(base64(deflate($xml)))&RelayState=urlencode($(relay_state))&SigAlg=urlencode($sig_alg)
// We rebuild it ourselves from the raw request, to avoid differences when url decoding/encoding.
signedQuery := func(query string) string {
m := make(map[string]string)
for query != "" {
key := query
if i := strings.IndexAny(key, "&;"); i >= 0 {
key, query = key[:i], key[i+1:]
} else {
query = ""
}
if key == "" {
continue
}
value := ""
if i := strings.Index(key, "="); i >= 0 {
key, value = key[:i], key[i+1:]
}
m[key] = value // Support only one value, but thats ok since we only want one and if really someone signed multiple values then its fine to fail.
}
s := new(strings.Builder)
for idx, key := range []string{kind, "RelayState", "SigAlg"} {
if value, ok := m[key]; ok {
if idx > 0 {
s.WriteString("&")
}
s.WriteString(key)
s.WriteString("=")
s.WriteString(value)
}
}
return s.String()
}(rawQuery)
// Create hash for the alg.
hash := hasher.New()
if _, hashErr := hash.Write([]byte(signedQuery)); hashErr != nil {
return fmt.Errorf("failed to hash: %w", hashErr)
}
hashed := hash.Sum(nil)
rsaPubKey, ok := pubKey.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("invalid RSA public key")
}
// NOTE(longsleep): All sig algs above, use PKCS1v15 with RSA.
if verifyErr := rsa.VerifyPKCS1v15(rsaPubKey, hasher, hashed, signature); verifyErr != nil {
return fmt.Errorf("signature verification failed: %w", verifyErr)
}
return nil
}
@@ -0,0 +1,125 @@
/*
* Copyright 2017-2020 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package samlext
import (
"bytes"
"compress/flate"
"crypto"
"encoding/base64"
"encoding/xml"
"fmt"
"io/ioutil"
"net/http"
"time"
"github.com/crewjam/saml"
)
// IdpLogoutRequest is used by IdentityProvider to handle a single logout request.
type IdpLogoutRequest struct {
HTTPRequest *http.Request
Binding string
RequestBuffer []byte
Request *saml.LogoutRequest
Now time.Time
RelayState string
SigAlg *string
Signature []byte
}
func NewIdpLogoutRequest(r *http.Request) (*IdpLogoutRequest, error) {
req := &IdpLogoutRequest{
HTTPRequest: r,
Now: saml.TimeNow(),
}
switch r.Method {
case http.MethodGet:
req.Binding = saml.HTTPRedirectBinding
compressedRequest, err := base64.StdEncoding.DecodeString(r.URL.Query().Get("SAMLRequest"))
if err != nil {
return nil, fmt.Errorf("cannot decode request: %w", err)
}
req.RequestBuffer, err = ioutil.ReadAll(flate.NewReader(bytes.NewReader(compressedRequest)))
if err != nil {
return nil, fmt.Errorf("cannot decompress request: %w", err)
}
req.RelayState = r.URL.Query().Get("RelayState")
sigAlgRaw := r.URL.Query().Get("SigAlg")
if sigAlgRaw != "" {
req.SigAlg = &sigAlgRaw
signature, err := base64.StdEncoding.DecodeString(r.URL.Query().Get("Signature"))
if err != nil {
return nil, fmt.Errorf("cannot decode signature: %w", err)
}
req.Signature = signature
}
case http.MethodPost:
if err := r.ParseForm(); err != nil {
return nil, err
}
req.Binding = saml.HTTPPostBinding
var err error
req.RequestBuffer, err = base64.StdEncoding.DecodeString(r.PostForm.Get("SAMLRequest"))
if err != nil {
return nil, err
}
req.RelayState = r.PostForm.Get("RelayState")
return nil, fmt.Errorf("parsing logout request from POST is not implemented")
default:
return nil, fmt.Errorf("method not allowed")
}
return req, nil
}
// Validate checks that the authentication request is valid and assigns
// the LogoutRequest and Metadata properties. Returns a non-nil error if the
// request is not valid.
func (req *IdpLogoutRequest) Validate() error {
request := &saml.LogoutRequest{}
if err := xml.Unmarshal(req.RequestBuffer, request); err != nil {
return err
}
req.Request = request
if req.Request.IssueInstant.Add(saml.MaxIssueDelay).Before(req.Now) {
return fmt.Errorf("request expired at %s", req.Request.IssueInstant.Add(saml.MaxIssueDelay))
}
if req.Request.Version != "2.0" {
return fmt.Errorf("expected SAML request version 2.0 got %v", req.Request.Version)
}
return nil
}
// VerifySignature verifies the associated IdpLogoutRequest data with the
// associated Signature using the provided public key.
func (req *IdpLogoutRequest) VerifySignature(pubKey crypto.PublicKey) error {
return VerifySignedHTTPRedirectQuery("SAMLRequest", req.HTTPRequest.URL.RawQuery, *req.SigAlg, req.Signature, pubKey)
}
@@ -0,0 +1,126 @@
/*
* Copyright 2017-2020 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package samlext
import (
"bytes"
"compress/flate"
"crypto"
"encoding/base64"
"encoding/xml"
"fmt"
"io/ioutil"
"net/http"
"time"
"github.com/crewjam/saml"
)
// IdpLogoutResponse is used by IdentityProvider to handle a single logout
// response callbacks.
type IdpLogoutResponse struct {
HTTPRequest *http.Request
Binding string
ResponseBuffer []byte
Response *saml.LogoutResponse
Now time.Time
RelayState string
SigAlg *string
Signature []byte
}
func NewIdpLogoutResponse(r *http.Request) (*IdpLogoutResponse, error) {
res := &IdpLogoutResponse{
HTTPRequest: r,
Now: saml.TimeNow(),
}
switch r.Method {
case http.MethodGet:
res.Binding = saml.HTTPRedirectBinding
compressedResponse, err := base64.StdEncoding.DecodeString(r.URL.Query().Get("SAMLResponse"))
if err != nil {
return nil, fmt.Errorf("cannot decode response: %w", err)
}
res.ResponseBuffer, err = ioutil.ReadAll(flate.NewReader(bytes.NewReader(compressedResponse)))
if err != nil {
return nil, fmt.Errorf("cannot decompress response: %w", err)
}
res.RelayState = r.URL.Query().Get("RelayState")
sigAlgRaw := r.URL.Query().Get("SigAlg")
if sigAlgRaw != "" {
res.SigAlg = &sigAlgRaw
signature, err := base64.StdEncoding.DecodeString(r.URL.Query().Get("Signature"))
if err != nil {
return nil, fmt.Errorf("cannot decode signature: %w", err)
}
res.Signature = signature
}
case http.MethodPost:
if err := r.ParseForm(); err != nil {
return nil, err
}
res.Binding = saml.HTTPPostBinding
var err error
res.ResponseBuffer, err = base64.StdEncoding.DecodeString(r.PostForm.Get("SAMLResponse"))
if err != nil {
return nil, err
}
res.RelayState = r.PostForm.Get("RelayState")
return nil, fmt.Errorf("parsing logout response from POST is not implemented")
default:
return nil, fmt.Errorf("method not allowed")
}
return res, nil
}
// Validate checks that the associated response is valid and assigns
// the LogoutResponse and Metadata properties. Returns a non-nil error if the
// request is not valid.
func (res *IdpLogoutResponse) Validate() error {
response := &saml.LogoutResponse{}
if err := xml.Unmarshal(res.ResponseBuffer, response); err != nil {
return err
}
res.Response = response
if res.Response.IssueInstant.Add(saml.MaxIssueDelay).Before(res.Now) {
return fmt.Errorf("response expired at %s", res.Response.IssueInstant.Add(saml.MaxIssueDelay))
}
if res.Response.Version != "2.0" {
return fmt.Errorf("expected SAML response version 2.0 got %v", res.Response.Version)
}
return nil
}
// VerifySignature verifies the associated IdpLogoutResponse data with the
// associated Signature using the provided public key.
func (res *IdpLogoutResponse) VerifySignature(pubKey crypto.PublicKey) error {
return VerifySignedHTTPRedirectQuery("SAMLResponse", res.HTTPRequest.URL.RawQuery, *res.SigAlg, res.Signature, pubKey)
}
@@ -0,0 +1,57 @@
/*
* Copyright 2017-2020 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package samlext
import (
"bytes"
"compress/flate"
"encoding/base64"
"net/url"
"github.com/beevik/etree"
"github.com/crewjam/saml"
)
type LogoutRequest struct {
*saml.LogoutRequest
}
// Redirect returns a URL suitable for using the redirect binding with the response.
func (req *LogoutRequest) Redirect(relayState string) *url.URL {
w := &bytes.Buffer{}
w1 := base64.NewEncoder(base64.StdEncoding, w)
w2, _ := flate.NewWriter(w1, 9)
doc := etree.NewDocument()
doc.SetRoot(req.Element())
if _, err := doc.WriteTo(w2); err != nil {
panic(err)
}
w2.Close()
w1.Close()
rv, _ := url.Parse(req.Destination)
query := rv.Query()
query.Set("SAMLRequest", w.String())
if relayState != "" {
query.Set("RelayState", relayState)
}
rv.RawQuery = query.Encode()
return rv
}
@@ -0,0 +1,88 @@
/*
* Copyright 2017-2020 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package samlext
import (
"bytes"
"compress/flate"
"encoding/base64"
"encoding/xml"
"fmt"
"net/url"
"github.com/crewjam/saml"
"github.com/longsleep/rndm"
)
func MakeLogoutResponse(sp *saml.ServiceProvider, req *saml.LogoutRequest, status *saml.Status, binding string) (*LogoutResponse, error) {
res := &LogoutResponse{&saml.LogoutResponse{
ID: fmt.Sprintf("id-%x", rndm.GenerateRandomBytes(20)),
InResponseTo: req.ID,
Version: "2.0",
IssueInstant: saml.TimeNow(),
Destination: sp.GetSLOBindingLocation(binding),
Issuer: &saml.Issuer{
Format: "urn:oasis:names:tc:SAML:2.0:nameid-format:entity",
Value: firstSet(sp.EntityID, sp.MetadataURL.String()),
},
}}
if status != nil {
res.LogoutResponse.Status = *status
}
return res, nil
}
func firstSet(a, b string) string {
if a == "" {
return b
}
return a
}
type LogoutResponse struct {
*saml.LogoutResponse
}
// Redirect returns a URL suitable for using the redirect binding with the response.
func (res *LogoutResponse) Redirect(relayState string) *url.URL {
w := &bytes.Buffer{}
w1 := base64.NewEncoder(base64.StdEncoding, w)
w2, _ := flate.NewWriter(w1, 9)
e := xml.NewEncoder(w2)
if err := e.Encode(res); err != nil {
panic(err)
}
w2.Close()
w1.Close()
rv, _ := url.Parse(res.Destination)
query := rv.Query()
query.Set("SAMLResponse", w.String())
if relayState != "" {
query.Set("RelayState", relayState)
}
rv.RawQuery = query.Encode()
return rv
}