mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-01-02 11:10:47 -05:00
To enhance the security of our application and prevent Cross-Site Request Forgery (CSRF) attacks, we have updated the SameSite attribute of the build in Identity Provider (IDP) cookies to Strict.
552 lines
18 KiB
Go
552 lines
18 KiB
Go
/*
|
|
* 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 bootstrap
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v4"
|
|
"github.com/longsleep/rndm"
|
|
"github.com/sirupsen/logrus"
|
|
|
|
"github.com/libregraph/lico/config"
|
|
"github.com/libregraph/lico/encryption"
|
|
"github.com/libregraph/lico/identity"
|
|
"github.com/libregraph/lico/managers"
|
|
oidcProvider "github.com/libregraph/lico/oidc/provider"
|
|
"github.com/libregraph/lico/utils"
|
|
)
|
|
|
|
// API types.
|
|
type APIType string
|
|
|
|
const (
|
|
APITypeKonnect APIType = "konnect"
|
|
APITypeSignin APIType = "signin"
|
|
)
|
|
|
|
// Defaults.
|
|
const (
|
|
DefaultSigningKeyID = "default"
|
|
DefaultSigningKeyBits = 2048
|
|
|
|
DefaultGuestIdentityManagerName = "guest"
|
|
DefaultCookieSameSite = http.SameSiteNoneMode
|
|
)
|
|
|
|
// Bootstrap is a data structure to hold configuration required to start
|
|
// konnectd.
|
|
type Bootstrap interface {
|
|
Config() *Config
|
|
Managers() *managers.Managers
|
|
|
|
MakeURIPath(api APIType, subpath string) string
|
|
}
|
|
|
|
// Implementation of the bootstrap interface.
|
|
type bootstrap struct {
|
|
config *Config
|
|
|
|
uriBasePath string
|
|
|
|
managers *managers.Managers
|
|
}
|
|
|
|
// Config returns the bootstap configuration.
|
|
func (bs *bootstrap) Config() *Config {
|
|
return bs.config
|
|
}
|
|
|
|
// Managers returns bootstrapped identity-managers.
|
|
func (bs *bootstrap) Managers() *managers.Managers {
|
|
return bs.managers
|
|
}
|
|
|
|
// Boot is the main entry point to bootstrap the service after validating the
|
|
// given configuration. The resulting Bootstrap struct can be used to retrieve
|
|
// configured identity-managers and their respective http-handlers and config.
|
|
//
|
|
// This function should be used by consumers which want to embed this project
|
|
// as a library.
|
|
func Boot(ctx context.Context, settings *Settings, cfg *config.Config) (Bootstrap, error) {
|
|
// NOTE(longsleep): Ensure to use same salt length as the hash size.
|
|
// See https://www.ietf.org/mail-archive/web/jose/current/msg02901.html for
|
|
// reference and https://github.com/golang-jwt/jwt/v4/issues/285 for
|
|
// the issue in upstream jwt-go.
|
|
for _, alg := range []string{jwt.SigningMethodPS256.Name, jwt.SigningMethodPS384.Name, jwt.SigningMethodPS512.Name} {
|
|
sm := jwt.GetSigningMethod(alg)
|
|
if signingMethodRSAPSS, ok := sm.(*jwt.SigningMethodRSAPSS); ok {
|
|
signingMethodRSAPSS.Options.SaltLength = rsa.PSSSaltLengthEqualsHash
|
|
}
|
|
}
|
|
|
|
bs := &bootstrap{
|
|
config: &Config{
|
|
Config: cfg,
|
|
Settings: settings,
|
|
},
|
|
}
|
|
|
|
err := bs.initialize(settings)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = bs.setup(ctx, settings)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return bs, nil
|
|
}
|
|
|
|
// initialize, parsed parameters from commandline with validation and adds them
|
|
// to the associated Bootstrap data.
|
|
func (bs *bootstrap) initialize(settings *Settings) error {
|
|
logger := bs.config.Config.Logger
|
|
var err error
|
|
|
|
if settings.IdentityManager == "" {
|
|
return fmt.Errorf("identity-manager argument missing, use one of kc, ldap, cookie, dummy")
|
|
}
|
|
|
|
bs.config.IssuerIdentifierURI, err = url.Parse(settings.Iss)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid iss value, iss is not a valid URL), %v", err)
|
|
} else if settings.Iss == "" {
|
|
return fmt.Errorf("missing iss value, did you provide the --iss parameter?")
|
|
} else if bs.config.IssuerIdentifierURI.Scheme != "https" {
|
|
return fmt.Errorf("invalid iss value, URL must start with https://")
|
|
} else if bs.config.IssuerIdentifierURI.Host == "" {
|
|
return fmt.Errorf("invalid iss value, URL must have a host")
|
|
}
|
|
|
|
bs.uriBasePath = settings.URIBasePath
|
|
|
|
bs.config.SignInFormURI, err = url.Parse(settings.SignInURI)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid sign-in URI, %v", err)
|
|
}
|
|
|
|
bs.config.SignedOutURI, err = url.Parse(settings.SignedOutURI)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid signed-out URI, %v", err)
|
|
}
|
|
|
|
bs.config.AuthorizationEndpointURI, err = url.Parse(settings.AuthorizationEndpointURI)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid authorization-endpoint-uri, %v", err)
|
|
}
|
|
|
|
bs.config.EndSessionEndpointURI, err = url.Parse(settings.EndsessionEndpointURI)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid endsession-endpoint-uri, %v", err)
|
|
}
|
|
|
|
if settings.Insecure {
|
|
// NOTE(longsleep): This disable http2 client support. See https://github.com/golang/go/issues/14275 for reasons.
|
|
bs.config.TLSClientConfig = utils.InsecureSkipVerifyTLSConfig()
|
|
logger.Warnln("insecure mode, TLS client connections are susceptible to man-in-the-middle attacks")
|
|
} else {
|
|
bs.config.TLSClientConfig = utils.DefaultTLSConfig()
|
|
}
|
|
|
|
for _, trustedProxy := range settings.TrustedProxy {
|
|
if ip := net.ParseIP(trustedProxy); ip != nil {
|
|
bs.config.Config.TrustedProxyIPs = append(bs.config.Config.TrustedProxyIPs, &ip)
|
|
continue
|
|
}
|
|
if _, ipNet, errParseCIDR := net.ParseCIDR(trustedProxy); errParseCIDR == nil {
|
|
bs.config.Config.TrustedProxyNets = append(bs.config.Config.TrustedProxyNets, ipNet)
|
|
continue
|
|
}
|
|
}
|
|
if len(bs.config.Config.TrustedProxyIPs) > 0 {
|
|
logger.Infoln("trusted proxy IPs", bs.config.Config.TrustedProxyIPs)
|
|
}
|
|
if len(bs.config.Config.TrustedProxyNets) > 0 {
|
|
logger.Infoln("trusted proxy networks", bs.config.Config.TrustedProxyNets)
|
|
}
|
|
|
|
if len(settings.AllowScope) > 0 {
|
|
bs.config.Config.AllowedScopes = settings.AllowScope
|
|
logger.Infoln("using custom allowed OAuth 2 scopes", bs.config.Config.AllowedScopes)
|
|
}
|
|
|
|
bs.config.Config.AllowClientGuests = settings.AllowClientGuests
|
|
if bs.config.Config.AllowClientGuests {
|
|
logger.Infoln("client controlled guests are enabled")
|
|
}
|
|
|
|
bs.config.Config.AllowDynamicClientRegistration = settings.AllowDynamicClientRegistration
|
|
if bs.config.Config.AllowDynamicClientRegistration {
|
|
logger.Infoln("dynamic client registration is enabled")
|
|
}
|
|
|
|
encryptionSecretFn := settings.EncryptionSecretFile
|
|
|
|
if encryptionSecretFn != "" {
|
|
logger.WithField("file", encryptionSecretFn).Infoln("loading encryption secret from file")
|
|
bs.config.EncryptionSecret, err = ioutil.ReadFile(encryptionSecretFn)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load encryption secret from file: %v", err)
|
|
}
|
|
if len(bs.config.EncryptionSecret) != encryption.KeySize {
|
|
return fmt.Errorf("invalid encryption secret size - must be %d bytes", encryption.KeySize)
|
|
}
|
|
} else {
|
|
logger.Warnf("missing --encryption-secret parameter, using random encyption secret with %d bytes", encryption.KeySize)
|
|
bs.config.EncryptionSecret = rndm.GenerateRandomBytes(encryption.KeySize)
|
|
}
|
|
|
|
bs.config.Config.ListenAddr = settings.Listen
|
|
|
|
bs.config.IdentifierClientDisabled = settings.IdentifierClientDisabled
|
|
bs.config.IdentifierClientPath = settings.IdentifierClientPath
|
|
|
|
bs.config.IdentifierRegistrationConf = settings.IdentifierRegistrationConf
|
|
if bs.config.IdentifierRegistrationConf != "" {
|
|
bs.config.IdentifierRegistrationConf, _ = filepath.Abs(bs.config.IdentifierRegistrationConf)
|
|
if _, errStat := os.Stat(bs.config.IdentifierRegistrationConf); errStat != nil {
|
|
return fmt.Errorf("identifier-registration-conf file not found or unable to access: %v", errStat)
|
|
}
|
|
bs.config.IdentifierAuthoritiesConf = bs.config.IdentifierRegistrationConf
|
|
}
|
|
|
|
bs.config.IdentifierScopesConf = settings.IdentifierScopesConf
|
|
if bs.config.IdentifierScopesConf != "" {
|
|
bs.config.IdentifierScopesConf, _ = filepath.Abs(bs.config.IdentifierScopesConf)
|
|
if _, errStat := os.Stat(bs.config.IdentifierScopesConf); errStat != nil {
|
|
return fmt.Errorf("identifier-scopes-conf file not found or unable to access: %v", errStat)
|
|
}
|
|
}
|
|
|
|
if settings.IdentifierDefaultBannerLogo != "" {
|
|
// Load from file.
|
|
b, errRead := ioutil.ReadFile(settings.IdentifierDefaultBannerLogo)
|
|
if errRead != nil {
|
|
return fmt.Errorf("identifier-default-banner-logo failed to open: %w", errRead)
|
|
}
|
|
bs.config.IdentifierDefaultBannerLogo = b
|
|
}
|
|
if settings.IdentifierDefaultSignInPageText != "" {
|
|
bs.config.IdentifierDefaultSignInPageText = &settings.IdentifierDefaultSignInPageText
|
|
}
|
|
if settings.IdentifierDefaultUsernameHintText != "" {
|
|
bs.config.IdentifierDefaultUsernameHintText = &settings.IdentifierDefaultUsernameHintText
|
|
}
|
|
bs.config.IdentifierUILocales = settings.IdentifierUILocales
|
|
|
|
bs.config.SigningKeyID = settings.SigningKid
|
|
bs.config.Signers = make(map[string]crypto.Signer)
|
|
bs.config.Validators = make(map[string]crypto.PublicKey)
|
|
bs.config.Certificates = make(map[string][]*x509.Certificate)
|
|
|
|
signingMethodString := settings.SigningMethod
|
|
bs.config.SigningMethod = jwt.GetSigningMethod(signingMethodString)
|
|
if bs.config.SigningMethod == nil {
|
|
return fmt.Errorf("unknown signing method: %s", signingMethodString)
|
|
}
|
|
|
|
signingKeyFns := settings.SigningPrivateKeyFiles
|
|
if len(signingKeyFns) > 0 {
|
|
first := true
|
|
for _, signingKeyFn := range signingKeyFns {
|
|
logger.WithField("path", signingKeyFn).Infoln("loading signing key")
|
|
err = addSignerWithIDFromFile(signingKeyFn, "", bs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if first {
|
|
// Also add key under the provided id.
|
|
first = false
|
|
err = addSignerWithIDFromFile(signingKeyFn, bs.config.SigningKeyID, bs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
//NOTE(longsleep): remove me - create keypair a random key pair.
|
|
sm := jwt.SigningMethodPS256
|
|
bs.config.SigningMethod = sm
|
|
logger.WithField("alg", sm.Name).Warnf("missing --signing-private-key parameter, using random %d bit signing key", DefaultSigningKeyBits)
|
|
signer, _ := rsa.GenerateKey(rand.Reader, DefaultSigningKeyBits)
|
|
bs.config.Signers[bs.config.SigningKeyID] = signer
|
|
}
|
|
|
|
// Ensure we have a signer for the things we need.
|
|
err = validateSigners(bs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
validationKeysPath := settings.ValidationKeysPath
|
|
if validationKeysPath != "" {
|
|
logger.WithField("path", validationKeysPath).Infoln("loading validation keys")
|
|
err = addValidatorsFromPath(validationKeysPath, bs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
bs.config.Config.HTTPTransport = utils.HTTPTransportWithTLSClientConfig(bs.config.TLSClientConfig)
|
|
|
|
bs.config.AccessTokenDurationSeconds = settings.AccessTokenDurationSeconds
|
|
if bs.config.AccessTokenDurationSeconds == 0 {
|
|
bs.config.AccessTokenDurationSeconds = 60 * 10 // 10 Minutes
|
|
}
|
|
bs.config.IDTokenDurationSeconds = settings.IDTokenDurationSeconds
|
|
if bs.config.IDTokenDurationSeconds == 0 {
|
|
bs.config.IDTokenDurationSeconds = 60 * 60 // 1 Hour
|
|
}
|
|
bs.config.RefreshTokenDurationSeconds = settings.RefreshTokenDurationSeconds
|
|
if bs.config.RefreshTokenDurationSeconds == 0 {
|
|
bs.config.RefreshTokenDurationSeconds = 60 * 60 * 24 * 365 * 3 // 3 Years
|
|
}
|
|
bs.config.DyamicClientSecretDurationSeconds = settings.DyamicClientSecretDurationSeconds
|
|
|
|
// add setting to allow setting the same site attribute of the cookies
|
|
bs.config.CookieSameSite = settings.CookieSameSite
|
|
if bs.config.CookieSameSite == 0 {
|
|
bs.config.CookieSameSite = DefaultCookieSameSite
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// setup takes care of setting up the managers based on the associated
|
|
// Bootstrap's data.
|
|
func (bs *bootstrap) setup(ctx context.Context, settings *Settings) error {
|
|
managers, err := newManagers(ctx, bs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
bs.managers = managers
|
|
|
|
identityManager, err := bs.setupIdentity(ctx, settings)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
managers.Set("identity", identityManager)
|
|
|
|
guestManager, err := bs.setupGuest(ctx, identityManager)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
managers.Set("guest", guestManager)
|
|
|
|
oidcProvider, err := bs.setupOIDCProvider(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
managers.Set("oidc", oidcProvider)
|
|
managers.Set("handler", oidcProvider) // Use OIDC provider as default HTTP handler.
|
|
|
|
err = managers.Apply()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to apply managers: %v", err)
|
|
}
|
|
|
|
// Final steps
|
|
err = oidcProvider.InitializeMetadata()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize provider metadata: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (bs *bootstrap) MakeURIPath(api APIType, subpath string) string {
|
|
subpath = strings.TrimPrefix(subpath, "/")
|
|
uriPath := ""
|
|
|
|
switch api {
|
|
case APITypeKonnect:
|
|
uriPath = fmt.Sprintf("%s/konnect/v1/%s", strings.TrimSuffix(bs.uriBasePath, "/"), subpath)
|
|
case APITypeSignin:
|
|
uriPath = fmt.Sprintf("%s/signin/v1/%s", strings.TrimSuffix(bs.uriBasePath, "/"), subpath)
|
|
default:
|
|
panic("unknown api type")
|
|
}
|
|
|
|
if subpath == "" {
|
|
uriPath = strings.TrimSuffix(uriPath, "/")
|
|
}
|
|
return uriPath
|
|
}
|
|
|
|
func (bs *bootstrap) MakeURI(api APIType, subpath string) *url.URL {
|
|
uriPath := bs.MakeURIPath(api, subpath)
|
|
uri, _ := url.Parse(bs.config.IssuerIdentifierURI.String())
|
|
uri.Path = uriPath
|
|
|
|
return uri
|
|
}
|
|
|
|
func (bs *bootstrap) setupIdentity(ctx context.Context, settings *Settings) (identity.Manager, error) {
|
|
logger := bs.config.Config.Logger
|
|
|
|
if settings.IdentityManager == "" {
|
|
return nil, fmt.Errorf("identity-manager argument missing")
|
|
}
|
|
|
|
// Identity manager.
|
|
identityManagerName := settings.IdentityManager
|
|
identityManager, err := getIdentityManagerByName(identityManagerName, bs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
logger.WithFields(logrus.Fields{
|
|
"name": identityManagerName,
|
|
"scopes": identityManager.ScopesSupported(nil),
|
|
"claims": identityManager.ClaimsSupported(nil),
|
|
}).Infoln("identity manager set up")
|
|
|
|
return identityManager, nil
|
|
}
|
|
|
|
func (bs *bootstrap) setupGuest(ctx context.Context, identityManager identity.Manager) (identity.Manager, error) {
|
|
if !bs.config.Config.AllowClientGuests {
|
|
return nil, nil
|
|
}
|
|
|
|
var err error
|
|
logger := bs.config.Config.Logger
|
|
|
|
guestManager, err := getIdentityManagerByName(DefaultGuestIdentityManagerName, bs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if guestManager != nil {
|
|
logger.Infoln("identity guest manager set up")
|
|
}
|
|
return guestManager, nil
|
|
}
|
|
|
|
func (bs *bootstrap) setupOIDCProvider(ctx context.Context) (*oidcProvider.Provider, error) {
|
|
var err error
|
|
logger := bs.config.Config.Logger
|
|
|
|
sessionCookiePath, err := getCommonURLPathPrefix(bs.config.AuthorizationEndpointURI.EscapedPath(), bs.config.EndSessionEndpointURI.EscapedPath())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to find common URL prefix for authorize and endsession: %v", err)
|
|
}
|
|
|
|
var registrationPath = ""
|
|
if bs.config.Config.AllowDynamicClientRegistration {
|
|
registrationPath = bs.MakeURIPath(APITypeKonnect, "/register")
|
|
}
|
|
|
|
provider, err := oidcProvider.NewProvider(&oidcProvider.Config{
|
|
Config: bs.config.Config,
|
|
|
|
IssuerIdentifier: bs.config.IssuerIdentifierURI.String(),
|
|
WellKnownPath: "/.well-known/openid-configuration",
|
|
JwksPath: bs.MakeURIPath(APITypeKonnect, "/jwks.json"),
|
|
AuthorizationPath: bs.config.AuthorizationEndpointURI.EscapedPath(),
|
|
TokenPath: bs.MakeURIPath(APITypeKonnect, "/token"),
|
|
UserInfoPath: bs.MakeURIPath(APITypeKonnect, "/userinfo"),
|
|
EndSessionPath: bs.config.EndSessionEndpointURI.EscapedPath(),
|
|
CheckSessionIframePath: bs.MakeURIPath(APITypeKonnect, "/session/check-session.html"),
|
|
RegistrationPath: registrationPath,
|
|
|
|
BrowserStateCookiePath: bs.MakeURIPath(APITypeKonnect, "/session/"),
|
|
BrowserStateCookieName: "__Secure-KKBS", // Kopano-Konnect-Browser-State
|
|
BrowserStateCookieSameSite: bs.config.CookieSameSite,
|
|
|
|
SessionCookiePath: sessionCookiePath,
|
|
SessionCookieName: "__Secure-KKCS", // Kopano-Konnect-Client-Session
|
|
SessionCookieSameSite: bs.config.CookieSameSite,
|
|
|
|
AccessTokenDuration: time.Duration(bs.config.AccessTokenDurationSeconds) * time.Second,
|
|
IDTokenDuration: time.Duration(bs.config.IDTokenDurationSeconds) * time.Second,
|
|
RefreshTokenDuration: time.Duration(bs.config.RefreshTokenDurationSeconds) * time.Second,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create provider: %v", err)
|
|
}
|
|
if bs.config.SigningMethod != nil {
|
|
err = provider.SetSigningMethod(bs.config.SigningMethod)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to set provider signing method: %v", err)
|
|
}
|
|
}
|
|
|
|
// All add signers.
|
|
for id, signer := range bs.config.Signers {
|
|
if id == bs.config.SigningKeyID {
|
|
err = provider.SetSigningKey(id, signer)
|
|
// Always set default key.
|
|
if id != DefaultSigningKeyID {
|
|
provider.SetValidationKey(DefaultSigningKeyID, signer.Public())
|
|
}
|
|
} else {
|
|
// Set non default signers as well.
|
|
err = provider.SetSigningKey(id, signer)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
// Add all validators.
|
|
for id, publicKey := range bs.config.Validators {
|
|
err = provider.SetValidationKey(id, publicKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
// Add all certificates.
|
|
for id, certificate := range bs.config.Certificates {
|
|
err = provider.SetCertificate(id, certificate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
sk, ok := provider.GetSigningKey(bs.config.SigningMethod)
|
|
if !ok {
|
|
return nil, fmt.Errorf("no signing key for selected signing method")
|
|
}
|
|
if bs.config.SigningKeyID == "" {
|
|
// Ensure that there is a default signing Key ID even if none was set.
|
|
provider.SetValidationKey(DefaultSigningKeyID, sk.PrivateKey.Public())
|
|
}
|
|
logger.WithFields(logrus.Fields{
|
|
"id": sk.ID,
|
|
"method": fmt.Sprintf("%T", sk.SigningMethod),
|
|
"alg": sk.SigningMethod.Alg(),
|
|
}).Infoln("oidc token signing default set up")
|
|
|
|
return provider, nil
|
|
}
|