369 lines
11 KiB
Go
369 lines
11 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 clients
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/libregraph/oidc-go"
|
|
"github.com/sirupsen/logrus"
|
|
"gopkg.in/yaml.v2"
|
|
)
|
|
|
|
// Registry implements the registry for registered clients.
|
|
type Registry struct {
|
|
mutex sync.RWMutex
|
|
|
|
trustedURI *url.URL
|
|
clients map[string]*ClientRegistration
|
|
|
|
allowDynamicClientRegistration bool
|
|
dynamicClientSecretDuration time.Duration
|
|
|
|
StatelessCreator func(ctx context.Context, signingMethod jwt.SigningMethod, claims jwt.Claims) (string, error)
|
|
StatelessValidator func(token *jwt.Token) (interface{}, error)
|
|
|
|
logger logrus.FieldLogger
|
|
}
|
|
|
|
// contextKey is an unexported type for keys defined in this package.
|
|
// This prevents collisions with keys defined in other packages.
|
|
type contextKey int
|
|
|
|
// claimsKey is the key for claims in contexts. It is
|
|
// unexported; clients use konnect.NewClaimsContext and
|
|
// connect.FromClaimsContext instead of using this key directly.
|
|
var registryKey contextKey
|
|
|
|
// NewRegistry created a new client Registry with the provided parameters.
|
|
func NewRegistry(ctx context.Context, trustedURI *url.URL, registrationConfFilepath string, allowDynamicClientRegistration bool, dynamicClientSecretDuration time.Duration, logger logrus.FieldLogger) (*Registry, error) {
|
|
registryData := &RegistryData{}
|
|
|
|
if registrationConfFilepath != "" {
|
|
logger.Debugf("parsing identifier registration conf from %v", registrationConfFilepath)
|
|
registryFile, err := ioutil.ReadFile(registrationConfFilepath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = yaml.Unmarshal(registryFile, registryData)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
r := &Registry{
|
|
trustedURI: trustedURI,
|
|
clients: make(map[string]*ClientRegistration),
|
|
|
|
allowDynamicClientRegistration: allowDynamicClientRegistration,
|
|
dynamicClientSecretDuration: dynamicClientSecretDuration,
|
|
|
|
logger: logger,
|
|
}
|
|
|
|
for _, client := range registryData.Clients {
|
|
validateErr := client.Validate()
|
|
registerErr := r.Register(client)
|
|
fields := logrus.Fields{
|
|
"client_id": client.ID,
|
|
"with_client_secret": client.Secret != "",
|
|
"trusted": client.Trusted,
|
|
"insecure": client.Insecure,
|
|
"application_type": client.ApplicationType,
|
|
"redirect_uris": client.RedirectURIs,
|
|
"origins": client.Origins,
|
|
}
|
|
|
|
if validateErr != nil {
|
|
logger.WithError(validateErr).WithFields(fields).Warnln("skipped registration of invalid client entry")
|
|
continue
|
|
}
|
|
if registerErr != nil {
|
|
logger.WithError(registerErr).WithFields(fields).Warnln("skipped registration of invalid client")
|
|
continue
|
|
}
|
|
logger.WithFields(fields).Debugln("registered client")
|
|
}
|
|
|
|
return r, nil
|
|
}
|
|
|
|
// NewRegistryContext returns a new Context that carries value provided Registry.
|
|
func NewRegistryContext(ctx context.Context, r *Registry) context.Context {
|
|
return context.WithValue(ctx, registryKey, r)
|
|
}
|
|
|
|
// FromRegistryContext returns the Registry value stored in ctx, if any.
|
|
func FromRegistryContext(ctx context.Context) (*Registry, bool) {
|
|
r, ok := ctx.Value(registryKey).(*Registry)
|
|
return r, ok
|
|
}
|
|
|
|
// Register validates the provided client registration and adds the client
|
|
// to the accociated registry if valid. Returns error otherwise.
|
|
func (r *Registry) Register(client *ClientRegistration) error {
|
|
if client.ID == "" {
|
|
return errors.New("invalid client_id")
|
|
}
|
|
|
|
if !client.Insecure && len(client.RedirectURIs) == 0 {
|
|
return errors.New("no redirect_uris")
|
|
}
|
|
|
|
switch client.ApplicationType {
|
|
case "":
|
|
client.ApplicationType = oidc.ApplicationTypeWeb
|
|
fallthrough
|
|
case oidc.ApplicationTypeWeb:
|
|
for _, urlString := range client.RedirectURIs {
|
|
// http://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata
|
|
parsed, _ := url.Parse(urlString)
|
|
if parsed == nil || parsed.Host == "" {
|
|
return fmt.Errorf("invalid redirect_uri %v - invalid or no hostname", urlString)
|
|
} else if !client.Insecure && parsed.Scheme != "https" {
|
|
return fmt.Errorf("invalid redirect_uri %v - make sure to use https when application_type is web", parsed)
|
|
} else if IsLocalNativeHTTPURI(parsed) {
|
|
return fmt.Errorf("invalid redirect_uri %v - host must not be localhost when application_type is web", parsed)
|
|
}
|
|
|
|
if len(client.Origins) == 0 {
|
|
// Auto add first redirect scheme and host as Origin if no
|
|
// origin is explicitly configured.
|
|
client.Origins = append(client.Origins, parsed.Scheme+"://"+parsed.Host)
|
|
}
|
|
}
|
|
if !client.Insecure && len(client.Origins) == 0 {
|
|
return errors.New("no origins - origin is required when application_type is web")
|
|
}
|
|
// breaks
|
|
|
|
case oidc.ApplicationTypeNative:
|
|
// breaks
|
|
for _, urlString := range client.RedirectURIs {
|
|
// http://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata
|
|
parsed, _ := url.Parse(urlString)
|
|
if parsed == nil || parsed.Host == "" {
|
|
return fmt.Errorf("invalid redirect_uri %v - invalid uri or no hostname", urlString)
|
|
} else if parsed.Scheme == "https" {
|
|
return fmt.Errorf("invalid redirect_uri %v - scheme must not be https when application_type is native", parsed)
|
|
} else if parsed.Scheme == "http" && !IsLocalNativeHTTPURI(parsed) {
|
|
return fmt.Errorf("invalid redirect_uri %v = http host must be localhost when application_type is native", parsed)
|
|
}
|
|
}
|
|
// breaks
|
|
|
|
default:
|
|
return fmt.Errorf("unknown application_type: %v", client.ApplicationType)
|
|
}
|
|
|
|
r.mutex.Lock()
|
|
defer r.mutex.Unlock()
|
|
r.clients[client.ID] = client
|
|
return nil
|
|
}
|
|
|
|
// Validate checks if the provided client registration data complies to the
|
|
// provided parameters and returns error when it does not.
|
|
func (r *Registry) Validate(client *ClientRegistration, clientSecret string, redirectURIString string, originURIString string, withoutSecret bool) error {
|
|
if client.ApplicationType == oidc.ApplicationTypeWeb {
|
|
if originURIString != "" && (!client.Insecure || len(client.Origins) > 0) {
|
|
// Compare originURI if it was given.
|
|
originOK := false
|
|
for _, urlString := range client.Origins {
|
|
if urlString == originURIString {
|
|
originOK = true
|
|
break
|
|
}
|
|
}
|
|
if !originOK {
|
|
return fmt.Errorf("invalid origin: %v", originURIString)
|
|
}
|
|
}
|
|
}
|
|
|
|
if redirectURIString != "" && (!client.Insecure || len(client.RedirectURIs) > 0) {
|
|
// Make sure to validate the redirect URI unless client is marked insecure
|
|
// and has no configured redirect URIs.
|
|
redirectURIOK := false
|
|
for _, registeredURIString := range client.RedirectURIs {
|
|
if client.ApplicationType == oidc.ApplicationTypeNative {
|
|
registeredURI, _ := url.Parse(registeredURIString)
|
|
if IsLocalNativeHTTPURI(registeredURI) {
|
|
redirectURI, err := url.Parse(redirectURIString)
|
|
if err != nil {
|
|
break
|
|
}
|
|
if IsLocalNativeHTTPURI(redirectURI) {
|
|
if registeredURI.Path == "" || redirectURI.Path == registeredURI.Path {
|
|
redirectURIOK = true
|
|
break
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
if registeredURIString == redirectURIString {
|
|
redirectURIOK = true
|
|
break
|
|
}
|
|
}
|
|
if !redirectURIOK {
|
|
return fmt.Errorf("invalid redirect_uri: %v", redirectURIString)
|
|
}
|
|
}
|
|
|
|
if !withoutSecret {
|
|
if valid, err := client.validateSecret(clientSecret); !valid {
|
|
return fmt.Errorf("invalid client_secret: %v", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Lookup returns and validates the clients Detail information for the provided
|
|
// parameters from the accociated registry.
|
|
func (r *Registry) Lookup(ctx context.Context, clientID string, clientSecret string, redirectURI *url.URL, originURIString string, withoutSecret bool) (*Details, error) {
|
|
var err error
|
|
var trusted bool
|
|
var dynamic bool
|
|
var displayName string
|
|
|
|
if clientID == "" {
|
|
return nil, fmt.Errorf("invalid client_id")
|
|
}
|
|
|
|
originURI, _ := url.Parse(originURIString)
|
|
|
|
// Implicit trust for web clients running and redirecting to the same origin
|
|
// as the issuer (ourselves).
|
|
if r.trustedURI != nil {
|
|
for {
|
|
if r.trustedURI.Scheme != redirectURI.Scheme || r.trustedURI.Host != redirectURI.Host {
|
|
break
|
|
}
|
|
if originURI.Scheme != "" && (r.trustedURI.Scheme != originURI.Scheme || r.trustedURI.Host != originURI.Host) {
|
|
break
|
|
}
|
|
trusted = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Lookup client registration.
|
|
r.mutex.RLock()
|
|
registration, _ := r.clients[clientID]
|
|
r.mutex.RUnlock()
|
|
|
|
if registration == nil && strings.HasPrefix(clientID, DynamicStatelessClientIDPrefix) {
|
|
trusted = false
|
|
dynamic = true
|
|
}
|
|
|
|
// Lookup dynamic clients when it makes sense.
|
|
if dynamic && registration == nil {
|
|
registration, _ = r.getDynamicClient(clientID)
|
|
}
|
|
|
|
if registration != nil {
|
|
redirectURIBase := &url.URL{
|
|
Scheme: redirectURI.Scheme,
|
|
Host: redirectURI.Host,
|
|
Path: redirectURI.Path,
|
|
}
|
|
err = r.Validate(registration, clientSecret, redirectURIBase.String(), originURIString, withoutSecret)
|
|
displayName = registration.Name
|
|
trusted = registration.Trusted
|
|
} else {
|
|
if trusted {
|
|
// Always let in implicitly trusted clients.
|
|
err = nil
|
|
} else {
|
|
err = fmt.Errorf("unknown client_id: %v", clientID)
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
redirecURIString := redirectURI.String()
|
|
|
|
r.logger.WithFields(logrus.Fields{
|
|
"trusted": trusted,
|
|
"client_id": clientID,
|
|
"redirect_uri": redirecURIString,
|
|
"known": registration != nil,
|
|
}).Debugln("identifier client lookup")
|
|
|
|
return &Details{
|
|
ID: clientID,
|
|
RedirectURI: redirecURIString,
|
|
DisplayName: displayName,
|
|
Trusted: trusted,
|
|
|
|
Registration: registration,
|
|
}, nil
|
|
}
|
|
|
|
// Get returns the registered clients registration for the provided client ID.
|
|
func (r *Registry) Get(ctx context.Context, clientID string) (*ClientRegistration, bool) {
|
|
// Lookup client registration.
|
|
r.mutex.RLock()
|
|
registration, ok := r.clients[clientID]
|
|
r.mutex.RUnlock()
|
|
if ok {
|
|
return registration, true
|
|
}
|
|
|
|
return r.getDynamicClient(clientID)
|
|
}
|
|
|
|
func (r *Registry) getDynamicClient(clientID string) (*ClientRegistration, bool) {
|
|
var registration *ClientRegistration
|
|
|
|
if len(clientID) >= len(DynamicStatelessClientIDPrefix) {
|
|
tokenString := clientID[len(DynamicStatelessClientIDPrefix):]
|
|
if token, err := jwt.ParseWithClaims(tokenString, &RegistrationClaims{}, func(token *jwt.Token) (interface{}, error) {
|
|
if r.StatelessValidator == nil {
|
|
return nil, fmt.Errorf("no validator for dynamic client ids")
|
|
}
|
|
return r.StatelessValidator(token)
|
|
}); err == nil {
|
|
if claims, ok := token.Claims.(*RegistrationClaims); ok && token.Valid {
|
|
// TODO(longsleep): Add secure client secret.
|
|
registration = claims.ClientRegistration
|
|
registration.ID = clientID
|
|
registration.Secret = claims.RegisteredClaims.Subject
|
|
registration.Dynamic = true
|
|
}
|
|
}
|
|
}
|
|
|
|
return registration, registration != nil
|
|
}
|