Initial QSfera import
This commit is contained in:
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 identity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/libregraph/lico/oidc/payload"
|
||||
)
|
||||
|
||||
// AuthRecord is an interface which provides identity auth information with scopes and claims..
|
||||
type AuthRecord interface {
|
||||
Manager() Manager
|
||||
Subject() string
|
||||
AuthorizedScopes() map[string]bool
|
||||
AuthorizeScopes(map[string]bool)
|
||||
AuthorizedClaims() *payload.ClaimsRequest
|
||||
AuthorizeClaims(*payload.ClaimsRequest)
|
||||
Claims(...string) []jwt.Claims
|
||||
|
||||
User() PublicUser
|
||||
SetUser(PublicUser)
|
||||
|
||||
LoggedOn() (bool, time.Time)
|
||||
SetAuthTime(time.Time)
|
||||
}
|
||||
+139
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
+225
@@ -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
@@ -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
|
||||
}
|
||||
+108
@@ -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
|
||||
}
|
||||
Generated
Vendored
+125
@@ -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)
|
||||
}
|
||||
Generated
Vendored
+126
@@ -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)
|
||||
}
|
||||
Generated
Vendored
+57
@@ -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
|
||||
}
|
||||
Generated
Vendored
+88
@@ -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
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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 identity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/libregraph/lico/oidc/payload"
|
||||
)
|
||||
|
||||
type authRecord struct {
|
||||
manager Manager
|
||||
|
||||
sub string
|
||||
authorizedScopes map[string]bool
|
||||
authorizedClaims *payload.ClaimsRequest
|
||||
claimsByScope map[string]jwt.Claims
|
||||
|
||||
user PublicUser
|
||||
authTime time.Time
|
||||
}
|
||||
|
||||
// NewAuthRecord returns a implementation of identity.AuthRecord holding
|
||||
// the provided data in memory.
|
||||
func NewAuthRecord(manager Manager, sub string, authorizedScopes map[string]bool, authorizedClaims *payload.ClaimsRequest, claimsByScope map[string]jwt.Claims) AuthRecord {
|
||||
if authorizedScopes == nil {
|
||||
authorizedScopes = make(map[string]bool)
|
||||
}
|
||||
|
||||
return &authRecord{
|
||||
manager: manager,
|
||||
|
||||
sub: sub,
|
||||
authorizedScopes: authorizedScopes,
|
||||
authorizedClaims: authorizedClaims,
|
||||
claimsByScope: claimsByScope,
|
||||
}
|
||||
}
|
||||
|
||||
// Manager implements the identity.AuthRecord interface, returning the
|
||||
// accociated identities manager.
|
||||
func (r *authRecord) Manager() Manager {
|
||||
return r.manager
|
||||
}
|
||||
|
||||
// Subject implements the identity.AuthRecord interface.
|
||||
func (r *authRecord) Subject() string {
|
||||
return r.sub
|
||||
}
|
||||
|
||||
// AuthorizedScopes implements the identity.AuthRecord interface.
|
||||
func (r *authRecord) AuthorizedScopes() map[string]bool {
|
||||
return r.authorizedScopes
|
||||
}
|
||||
|
||||
// AuthorizeScopes implements the identity.AuthRecord interface.
|
||||
func (r *authRecord) AuthorizeScopes(scopes map[string]bool) {
|
||||
authorizedScopes, unauthorizedScopes := AuthorizeScopes(r.manager, r.User(), scopes)
|
||||
|
||||
for scope, grant := range authorizedScopes {
|
||||
if grant {
|
||||
r.authorizedScopes[scope] = grant
|
||||
} else {
|
||||
delete(r.authorizedScopes, scope)
|
||||
}
|
||||
}
|
||||
for scope := range unauthorizedScopes {
|
||||
delete(r.authorizedScopes, scope)
|
||||
}
|
||||
}
|
||||
|
||||
// AuthorizedClaims implements the identity.AuthRecord interface.
|
||||
func (r *authRecord) AuthorizedClaims() *payload.ClaimsRequest {
|
||||
return r.authorizedClaims
|
||||
}
|
||||
|
||||
// AuthorizeClaims implements the identity.AuthRecord interface.
|
||||
func (r *authRecord) AuthorizeClaims(claims *payload.ClaimsRequest) {
|
||||
r.authorizedClaims = claims
|
||||
}
|
||||
|
||||
// Claims implements the identity.AuthRecord interface.
|
||||
func (r *authRecord) Claims(scopes ...string) []jwt.Claims {
|
||||
result := make([]jwt.Claims, len(scopes))
|
||||
for idx, scope := range scopes {
|
||||
if claimsForScope, ok := r.claimsByScope[scope]; ok {
|
||||
result[idx] = claimsForScope
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// User implements the identity.AuthRecord interface.
|
||||
func (r *authRecord) User() PublicUser {
|
||||
return r.user
|
||||
}
|
||||
|
||||
// SetUser implements the identity.AuthRecord interface.
|
||||
func (r *authRecord) SetUser(u PublicUser) {
|
||||
r.user = u
|
||||
}
|
||||
|
||||
// LoggedOn implements the identity.AuthRecord interface
|
||||
func (r *authRecord) LoggedOn() (bool, time.Time) {
|
||||
return !r.authTime.IsZero(), r.authTime
|
||||
}
|
||||
|
||||
// SetAuthTime implements the identity.AuthRecord interface.
|
||||
func (r *authRecord) SetAuthTime(authTime time.Time) {
|
||||
r.authTime = authTime
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// RegistrationClaims are claims used to with dynamic clients.
|
||||
type RegistrationClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
|
||||
*ClientRegistration
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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 (
|
||||
"crypto"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Details hold detail information about clients identified by ID.
|
||||
type Details struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
Trusted bool `json:"trusted"`
|
||||
|
||||
Registration *ClientRegistration `json:"-"`
|
||||
}
|
||||
|
||||
// A Secured is a client records public key identified by ID.
|
||||
type Secured struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
ApplicationType string
|
||||
|
||||
Kid string
|
||||
PublicKey crypto.PublicKey
|
||||
|
||||
TrustedScopes []string
|
||||
|
||||
Registration *ClientRegistration
|
||||
}
|
||||
|
||||
// IsLocalNativeHTTPURI returns true if the provided URI qualifies to be used
|
||||
// as http redirect URI for a native client.
|
||||
func IsLocalNativeHTTPURI(uri *url.URL) bool {
|
||||
if uri.Scheme != "http" {
|
||||
return false
|
||||
}
|
||||
return IsLocalNativeHostURI(uri)
|
||||
}
|
||||
|
||||
// IsLocalNativeHostURI returns true if the provided URI hostname is considered
|
||||
// as localhost for a native client.
|
||||
func IsLocalNativeHostURI(uri *url.URL) bool {
|
||||
hostname := uri.Hostname()
|
||||
return hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1"
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* 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"
|
||||
"crypto"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/longsleep/rndm"
|
||||
"github.com/mendsley/gojwk"
|
||||
"golang.org/x/crypto/blake2b"
|
||||
_ "gopkg.in/yaml.v2" // Make sure we have yaml.
|
||||
)
|
||||
|
||||
// Constat data used with dynamic stateless clients.
|
||||
const (
|
||||
DynamicStatelessClientIDPrefix = "dyn."
|
||||
DynamicStatelessClientStaticSaltV1 = "konnect-client-v1"
|
||||
)
|
||||
|
||||
// RegistryData is the base structur of our client registry configuration file.
|
||||
type RegistryData struct {
|
||||
Clients []*ClientRegistration `yaml:"clients,flow"`
|
||||
}
|
||||
|
||||
// ClientRegistration defines a client with its properties.
|
||||
type ClientRegistration struct {
|
||||
ID string `yaml:"id" json:"-"`
|
||||
Secret string `yaml:"secret" json:"-"`
|
||||
|
||||
Trusted bool `yaml:"trusted" json:"-"`
|
||||
TrustedScopes []string `yaml:"trusted_scopes" json:"-"`
|
||||
Insecure bool `yaml:"insecure" json:"-"`
|
||||
|
||||
ImplicitScopes []string `yaml:"implicit_scopes" json:"-"`
|
||||
|
||||
Dynamic bool `yaml:"-" json:"-"`
|
||||
IDIssuedAt time.Time `yaml:"-" json:"-"`
|
||||
SecretExpiresAt time.Time `yaml:"-" json:"-"`
|
||||
|
||||
Contacts []string `yaml:"contacts,flow" json:"contacts,omitempty"`
|
||||
Name string `yaml:"name" json:"name,omitempty"`
|
||||
URI string `yaml:"uri" json:"uri,omitempty"`
|
||||
GrantTypes []string `yaml:"grant_types,flow" json:"grant_types,omitempty"`
|
||||
ApplicationType string `yaml:"application_type" json:"application_type,omitempty"`
|
||||
|
||||
RedirectURIs []string `yaml:"redirect_uris,flow" json:"redirect_uris,omitempty"`
|
||||
Origins []string `yaml:"origins,flow" json:"-"`
|
||||
|
||||
JWKS *gojwk.Key `yaml:"jwks" json:"-"`
|
||||
|
||||
RawIDTokenSignedResponseAlg string `yaml:"id_token_signed_response_alg" json:"id_token_signed_response_alg,omitempty"`
|
||||
RawUserInfoSignedResponseAlg string `yaml:"userinfo_signed_response_alg" json:"userinfo_signed_response_alg,omitempty"`
|
||||
RawRequestObjectSigningAlg string `yaml:"request_object_signing_alg" json:"request_object_signing_alg,omitempty"`
|
||||
RawTokenEndpointAuthMethod string `yaml:"token_endpoint_auth_method" json:"token_endpoint_auth_method,omitempty"`
|
||||
RawTokenEndpointAuthSigningAlg string `yaml:"token_endpoint_auth_signing_alg" json:"token_endpoint_auth_signing_alg,omitempty"`
|
||||
|
||||
PostLogoutRedirectURIs []string `yaml:"post_logout_redirect_uris,flow" json:"post_logout_redirect_uris,omitempty"`
|
||||
}
|
||||
|
||||
// Validate validates the associated client registration data and returns error
|
||||
// if the data is not valid.
|
||||
func (cr *ClientRegistration) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Secure looks up the a matching key from the accociated client registration
|
||||
// and returns its public key part as a secured client.
|
||||
func (cr *ClientRegistration) Secure(rawKid interface{}) (*Secured, error) {
|
||||
var kid string
|
||||
var key crypto.PublicKey
|
||||
var err error
|
||||
|
||||
switch len(cr.JWKS.Keys) {
|
||||
case 0:
|
||||
// breaks
|
||||
case 1:
|
||||
// Use the one and only, no matter what kid says.
|
||||
key, err = cr.JWKS.Keys[0].DecodePublicKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kid = cr.JWKS.Keys[0].Kid
|
||||
default:
|
||||
// Find by kid.
|
||||
kid, _ = rawKid.(string)
|
||||
if kid == "" {
|
||||
kid = "default"
|
||||
}
|
||||
for _, k := range cr.JWKS.Keys {
|
||||
if kid == k.Kid {
|
||||
key, err = k.DecodePublicKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if key == nil {
|
||||
return nil, fmt.Errorf("unknown kid")
|
||||
}
|
||||
|
||||
return &Secured{
|
||||
ID: cr.ID,
|
||||
DisplayName: cr.Name,
|
||||
ApplicationType: cr.ApplicationType,
|
||||
|
||||
Kid: kid,
|
||||
PublicKey: key,
|
||||
|
||||
TrustedScopes: cr.TrustedScopes,
|
||||
|
||||
Registration: cr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetDynamic modifieds the required data for the associated client registration
|
||||
// so it becomes a dynamic client.
|
||||
func (cr *ClientRegistration) SetDynamic(ctx context.Context, creator func(ctx context.Context, signingMethod jwt.SigningMethod, claims jwt.Claims) (string, error)) error {
|
||||
if creator == nil {
|
||||
return fmt.Errorf("no creator")
|
||||
}
|
||||
|
||||
if cr.ID != "" {
|
||||
return fmt.Errorf("has ID already")
|
||||
}
|
||||
|
||||
registry, ok := FromRegistryContext(ctx)
|
||||
if !ok {
|
||||
return fmt.Errorf("no registry")
|
||||
}
|
||||
|
||||
// Initialize basic client registration data for dynamic client.
|
||||
cr.IDIssuedAt = time.Now()
|
||||
if registry.dynamicClientSecretDuration > 0 {
|
||||
cr.SecretExpiresAt = time.Now().Add(registry.dynamicClientSecretDuration)
|
||||
}
|
||||
cr.Dynamic = true
|
||||
|
||||
sub, secret, err := cr.makeSecret(nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to make dynamic client secret: %v", err)
|
||||
}
|
||||
|
||||
// Stateless Dynamic Client Registration encodes all relevant data in the
|
||||
// client_id. See https://openid.net/specs/openid-connect-registration-1_0.html#StatelessRegistration
|
||||
// for more information. We use a JWT as client_id.
|
||||
claims := &RegistrationClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: sub,
|
||||
IssuedAt: jwt.NewNumericDate(cr.IDIssuedAt),
|
||||
ExpiresAt: jwt.NewNumericDate(cr.SecretExpiresAt),
|
||||
},
|
||||
ClientRegistration: cr,
|
||||
}
|
||||
|
||||
// Create signed stateless client ID by help of the provided creator function.
|
||||
id, err := creator(ctx, nil, claims)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fill in ID and secret.
|
||||
cr.ID = DynamicStatelessClientIDPrefix + id
|
||||
cr.Secret = secret
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyImplicitScopes apples the associated registration's implicit scopes to
|
||||
// the provided scopes map.
|
||||
func (cr *ClientRegistration) ApplyImplicitScopes(scopes map[string]bool) error {
|
||||
for _, scope := range cr.ImplicitScopes {
|
||||
if scope != "" {
|
||||
scopes[scope] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cr *ClientRegistration) makeSecret(secret []byte) (string, string, error) {
|
||||
// Create random secret. HMAC the client name with it to get the subject.
|
||||
if secret == nil {
|
||||
secret = rndm.GenerateRandomBytes(64)
|
||||
}
|
||||
|
||||
hasher, err := blake2b.New512(secret)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create hasher for dynamic client_id: %v", err)
|
||||
}
|
||||
hasher.Write([]byte(cr.Name))
|
||||
hasher.Write([]byte(" "))
|
||||
hasher.Write([]byte(DynamicStatelessClientStaticSaltV1))
|
||||
sub := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
return sub, base64.RawURLEncoding.EncodeToString(secret), nil
|
||||
}
|
||||
|
||||
func (cr *ClientRegistration) validateSecret(clientSecret string) (bool, error) {
|
||||
if cr.Dynamic {
|
||||
if cr.Secret == "" {
|
||||
// Fail fast, since dynamic clients must have a secret.
|
||||
return false, fmt.Errorf("no secret in registration")
|
||||
}
|
||||
|
||||
// Dynamic clients use hashed passwords.
|
||||
secret, err := base64.RawURLEncoding.DecodeString(clientSecret)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to decode client secret: %v", err)
|
||||
}
|
||||
sub, _, err := cr.makeSecret(secret)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to produce client secret for comparison: %v", err)
|
||||
}
|
||||
|
||||
return subtle.ConstantTimeCompare([]byte(sub), []byte(cr.Secret)) == 1, nil
|
||||
}
|
||||
|
||||
if cr.Secret != "" && subtle.ConstantTimeCompare([]byte(clientSecret), []byte(cr.Secret)) != 1 {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 identity
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Config defines a IdentityManager's configuration settings.
|
||||
type Config struct {
|
||||
SignInFormURI *url.URL
|
||||
SignedOutURI *url.URL
|
||||
|
||||
ScopesSupported []string
|
||||
|
||||
Logger logrus.FieldLogger
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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 identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// key is an unexported type for keys defined in this package.
|
||||
// This prevents collisions with keys defined in other packages.
|
||||
type key int
|
||||
|
||||
// authRecordKey is the key for identity.AuthRecord in Contexts. It is
|
||||
// unexported; clients use identity.NewContext and identity.FromContext
|
||||
// instead of using this key directly.
|
||||
const (
|
||||
authRecordKey key = iota
|
||||
)
|
||||
|
||||
// NewContext returns a new Context that carries value auth.
|
||||
func NewContext(ctx context.Context, auth AuthRecord) context.Context {
|
||||
return context.WithValue(ctx, authRecordKey, auth)
|
||||
}
|
||||
|
||||
// FromContext returns the AuthRecord value stored in ctx, if any.
|
||||
func FromContext(ctx context.Context) (AuthRecord, bool) {
|
||||
auth, ok := ctx.Value(authRecordKey).(AuthRecord)
|
||||
return auth, ok
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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 identity
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// IsHandledError is an error which tells that the backend has handled
|
||||
// the request and all further handling should stop
|
||||
type IsHandledError struct {
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (err *IsHandledError) Error() string {
|
||||
return "is_handled"
|
||||
}
|
||||
|
||||
// RedirectError is an error which backends can return if a
|
||||
// redirection is required.
|
||||
type RedirectError struct {
|
||||
id string
|
||||
redirectURI *url.URL
|
||||
}
|
||||
|
||||
// NewRedirectError creates a new corresponding error with the
|
||||
// provided id and redirect URL.
|
||||
func NewRedirectError(id string, redirectURI *url.URL) *RedirectError {
|
||||
return &RedirectError{
|
||||
id: id,
|
||||
redirectURI: redirectURI,
|
||||
}
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (err *RedirectError) Error() string {
|
||||
return err.id
|
||||
}
|
||||
|
||||
// RedirectURI returns the redirection URL of the accociated error.
|
||||
func (err *RedirectError) RedirectURI() *url.URL {
|
||||
return err.redirectURI
|
||||
}
|
||||
|
||||
// LoginRequiredError which backends can return to indicate that sign-in is
|
||||
// required.
|
||||
type LoginRequiredError struct {
|
||||
id string
|
||||
signInURI *url.URL
|
||||
}
|
||||
|
||||
// NewLoginRequiredError creates a new corresponding error with the provided id.
|
||||
func NewLoginRequiredError(id string, signInURI *url.URL) *LoginRequiredError {
|
||||
return &LoginRequiredError{
|
||||
id: id,
|
||||
signInURI: signInURI,
|
||||
}
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (err *LoginRequiredError) Error() string {
|
||||
return err.id
|
||||
}
|
||||
|
||||
// SignInURI returns the sign-in URL of the accociated error.
|
||||
func (err *LoginRequiredError) SignInURI() *url.URL {
|
||||
return err.signInURI
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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 identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/libregraph/lico/oidc/payload"
|
||||
)
|
||||
|
||||
// Manager is a interface to define a identity manager.
|
||||
type Manager interface {
|
||||
Authenticate(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, next Manager) (AuthRecord, error)
|
||||
Authorize(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, auth AuthRecord) (AuthRecord, error)
|
||||
EndSession(ctx context.Context, rw http.ResponseWriter, req *http.Request, esr *payload.EndSessionRequest) error
|
||||
|
||||
ApproveScopes(ctx context.Context, sub string, audience string, approvedScopesList map[string]bool) (string, error)
|
||||
ApprovedScopes(ctx context.Context, sub string, audience string, ref string) (map[string]bool, error)
|
||||
|
||||
Fetch(ctx context.Context, userID string, sessionRef *string, scopes map[string]bool, requestedClaimsMaps []*payload.ClaimsRequestMap, requestedScopes map[string]bool) (AuthRecord, bool, error)
|
||||
|
||||
Name() string
|
||||
ScopesSupported(scopes map[string]bool) []string
|
||||
ClaimsSupported(claims []string) []string
|
||||
|
||||
AddRoutes(ctx context.Context, router *mux.Router)
|
||||
|
||||
OnSetLogon(func(ctx context.Context, rw http.ResponseWriter, user User) error) error
|
||||
OnUnsetLogon(func(ctx context.Context, rw http.ResponseWriter) error) error
|
||||
}
|
||||
+475
@@ -0,0 +1,475 @@
|
||||
/*
|
||||
* 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 managers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/libregraph/oidc-go"
|
||||
"github.com/longsleep/rndm"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
konnect "github.com/libregraph/lico"
|
||||
"github.com/libregraph/lico/identity"
|
||||
"github.com/libregraph/lico/managers"
|
||||
"github.com/libregraph/lico/oidc/payload"
|
||||
"github.com/libregraph/lico/version"
|
||||
)
|
||||
|
||||
const cookieIdentityManagerName = "cookie"
|
||||
|
||||
// CookieIdentityManager implements an identity manager which passes through
|
||||
// received HTTP cookies to a HTTP backend..
|
||||
type CookieIdentityManager struct {
|
||||
backendURI *url.URL
|
||||
allowedCookies map[string]bool
|
||||
|
||||
scopesSupported []string
|
||||
|
||||
signInFormURI string
|
||||
logger logrus.FieldLogger
|
||||
|
||||
client *http.Client
|
||||
|
||||
encryptionManager *EncryptionManager
|
||||
}
|
||||
|
||||
// NewCookieIdentityManager creates a new CookieIdentityManager from the
|
||||
// provided parameters.
|
||||
func NewCookieIdentityManager(c *identity.Config, backendURI *url.URL, cookieNames []string, timeout time.Duration, transport http.RoundTripper) *CookieIdentityManager {
|
||||
if transport == nil {
|
||||
transport = http.DefaultTransport
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
var allowedCookies map[string]bool
|
||||
if len(cookieNames) != 0 {
|
||||
allowedCookies = make(map[string]bool)
|
||||
for _, n := range cookieNames {
|
||||
allowedCookies[n] = true
|
||||
}
|
||||
}
|
||||
|
||||
im := &CookieIdentityManager{
|
||||
backendURI: backendURI,
|
||||
allowedCookies: allowedCookies,
|
||||
|
||||
signInFormURI: c.SignInFormURI.String(),
|
||||
logger: c.Logger,
|
||||
|
||||
client: client,
|
||||
|
||||
scopesSupported: setupSupportedScopes([]string{
|
||||
oidc.ScopeProfile,
|
||||
oidc.ScopeEmail,
|
||||
konnect.ScopeNumericID,
|
||||
}, nil, c.ScopesSupported),
|
||||
}
|
||||
|
||||
return im
|
||||
}
|
||||
|
||||
// RegisterManagers registers the provided managers,
|
||||
func (im *CookieIdentityManager) RegisterManagers(mgrs *managers.Managers) error {
|
||||
im.encryptionManager = mgrs.Must("encryption").(*EncryptionManager)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type cookieUser struct {
|
||||
raw string
|
||||
name string
|
||||
email string
|
||||
|
||||
id int64
|
||||
claims jwt.MapClaims
|
||||
}
|
||||
|
||||
func (u *cookieUser) Raw() string {
|
||||
return u.raw
|
||||
}
|
||||
|
||||
func (u *cookieUser) Subject() string {
|
||||
sub, _ := getPublicSubject([]byte(u.raw), []byte(cookieIdentityManagerName))
|
||||
return sub
|
||||
}
|
||||
|
||||
func (u *cookieUser) Name() string {
|
||||
return u.name
|
||||
}
|
||||
|
||||
func (u *cookieUser) Email() string {
|
||||
return u.email
|
||||
}
|
||||
|
||||
func (u *cookieUser) EmailVerified() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (u *cookieUser) ID() int64 {
|
||||
return u.id
|
||||
}
|
||||
|
||||
func (u *cookieUser) Claims() jwt.MapClaims {
|
||||
return u.claims
|
||||
}
|
||||
|
||||
type cookieBackendResponse struct {
|
||||
Subject string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (im *CookieIdentityManager) backendRequest(ctx context.Context, encodedCookies string, headers http.Header) (*cookieUser, error) {
|
||||
if encodedCookies == "" {
|
||||
// Fastpath, do nothing when no cookies.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, im.backendURI.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
// Copy over some request headers which are used for fingerprinting sessions.
|
||||
request.Header.Set("Accept-Language", headers.Get("Accept-Language"))
|
||||
request.Header.Set("User-Agent", headers.Get("User-Agent"))
|
||||
|
||||
request.Header.Set("Connection", "keep-alive") // XXX(longsleep): This is part of the Kopano Webapp finger print and we do not really know what the browser sent on sign-in :/
|
||||
request.Header.Set("X-Konnect-Request", fmt.Sprintf("1/%s", version.Version))
|
||||
|
||||
request.Header.Set("Cookie", encodedCookies)
|
||||
request = request.WithContext(ctx)
|
||||
|
||||
response, err := im.client.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
body, err := ioutil.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response failed: %v", err)
|
||||
}
|
||||
|
||||
switch response.StatusCode {
|
||||
case http.StatusOK:
|
||||
fallthrough
|
||||
case http.StatusAccepted:
|
||||
// breaks
|
||||
case http.StatusUnauthorized:
|
||||
fallthrough
|
||||
case http.StatusForbidden:
|
||||
// Not signed in.
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("request returned error code: %v", response.StatusCode)
|
||||
}
|
||||
|
||||
payload := &cookieBackendResponse{}
|
||||
err = json.Unmarshal(body, payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
encryptedCookies, err := im.encryptionManager.EncryptStringToHexString(encodedCookies)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt cookies: %v", err)
|
||||
}
|
||||
|
||||
claims := make(jwt.MapClaims)
|
||||
claims["cookie.v"] = encryptedCookies
|
||||
claims["cookie.al"] = headers.Get("Accept-Language")
|
||||
claims["cookie.ua"] = headers.Get("User-Agent")
|
||||
claims[konnect.IdentifiedUserIDClaim] = payload.Subject
|
||||
|
||||
user := &cookieUser{
|
||||
raw: payload.Subject,
|
||||
email: payload.Email,
|
||||
name: payload.Name,
|
||||
|
||||
id: payload.ID,
|
||||
claims: claims,
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Authenticate implements the identity.Manager interface.
|
||||
func (im *CookieIdentityManager) Authenticate(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, next identity.Manager) (identity.AuthRecord, error) {
|
||||
// Process incoming cookies, filter, and encode to string.
|
||||
var encodedCookies []string
|
||||
for _, cookie := range req.Cookies() {
|
||||
if im.allowedCookies != nil {
|
||||
if allowed, _ := im.allowedCookies[cookie.Name]; !allowed {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
encodedCookies = append(encodedCookies, cookie.String())
|
||||
}
|
||||
encodedCookiesString := strings.Join(encodedCookies, "; ")
|
||||
|
||||
user, err := im.backendRequest(ctx, encodedCookiesString, req.Header)
|
||||
if err != nil {
|
||||
// Error, directly return.
|
||||
im.logger.Errorln("CookieIdentityManager: backend request error", err)
|
||||
return nil, ar.NewError(oidc.ErrorCodeOAuth2ServerError, "CookieIdentityManager: backend request error")
|
||||
}
|
||||
if user == nil {
|
||||
// Not signed in.
|
||||
err = ar.NewError(oidc.ErrorCodeOIDCLoginRequired, "CookieIdentityManager: not signed in")
|
||||
}
|
||||
|
||||
// Check prompt value.
|
||||
switch {
|
||||
case ar.Prompts[oidc.PromptNone] == true:
|
||||
if err != nil {
|
||||
// Never show sign-in, directly return error.
|
||||
return nil, err
|
||||
}
|
||||
case ar.Prompts[oidc.PromptLogin] == true:
|
||||
if err == nil {
|
||||
// Enforce to show sign-in, when signed in.
|
||||
err = ar.NewError(oidc.ErrorCodeOIDCLoginRequired, "CookieIdentityManager: prompt=login request")
|
||||
}
|
||||
case ar.Prompts[oidc.PromptSelectAccount] == true:
|
||||
// Not supported, just ignore.
|
||||
fallthrough
|
||||
default:
|
||||
// Let all other prompt values pass.
|
||||
}
|
||||
|
||||
// More checks.
|
||||
if err == nil {
|
||||
var sub string
|
||||
if user != nil {
|
||||
sub = user.Subject()
|
||||
}
|
||||
err = ar.Verify(sub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
u, _ := url.Parse(im.signInFormURI)
|
||||
return nil, identity.NewLoginRequiredError(err.Error(), u)
|
||||
}
|
||||
|
||||
auth := identity.NewAuthRecord(im, user.Subject(), nil, nil, nil)
|
||||
auth.SetUser(user)
|
||||
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// Authorize implements the identity.Manager interface.
|
||||
func (im *CookieIdentityManager) Authorize(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, auth identity.AuthRecord) (identity.AuthRecord, error) {
|
||||
promptConsent := false
|
||||
var approvedScopes map[string]bool
|
||||
|
||||
// Check prompt value.
|
||||
switch {
|
||||
case ar.Prompts[oidc.PromptConsent] == true:
|
||||
promptConsent = true
|
||||
default:
|
||||
// Let all other prompt values pass.
|
||||
}
|
||||
|
||||
// Fastpath for known clients.
|
||||
switch ar.ClientID {
|
||||
default:
|
||||
// TODO(longsleep): Implement previous consent checks via backend.
|
||||
approvedScopes = ar.Scopes
|
||||
}
|
||||
|
||||
// Offline access validation.
|
||||
// http://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
|
||||
if ok, _ := ar.Scopes[oidc.ScopeOfflineAccess]; ok {
|
||||
if !promptConsent {
|
||||
// Ensure that the prompt parameter contains consent unless
|
||||
// other conditions for processing the request permitting offline
|
||||
// access to the requested resources are in place; unless one or
|
||||
// both of these conditions are fulfilled, then it MUST ignore the
|
||||
// offline_access request,
|
||||
delete(ar.Scopes, oidc.ScopeOfflineAccess)
|
||||
im.logger.Debugln("consent is required for offline access but not given, removed offline_access scope")
|
||||
} else {
|
||||
// NOTE(longsleep): Cookie identity relies on the presence of session cookies know to a backend. Thus offline access is not supported.
|
||||
im.logger.Warnf("CookieIdentityManager: offline_access requested but not supported, removed offline_access scope")
|
||||
delete(ar.Scopes, oidc.ScopeOfflineAccess)
|
||||
}
|
||||
}
|
||||
|
||||
if promptConsent {
|
||||
if ar.Prompts[oidc.PromptNone] == true {
|
||||
return auth, ar.NewError(oidc.ErrorCodeOIDCInteractionRequired, "consent required")
|
||||
}
|
||||
|
||||
// TODO(longsleep): Implement permissions page / consent prompt.
|
||||
return auth, ar.NewError(oidc.ErrorCodeOIDCInteractionRequired, "consent required")
|
||||
}
|
||||
|
||||
auth.AuthorizeScopes(approvedScopes)
|
||||
auth.AuthorizeClaims(ar.Claims)
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// EndSession implements the identity.Manager interface.
|
||||
func (im *CookieIdentityManager) EndSession(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.EndSessionRequest) error {
|
||||
// XXX
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApproveScopes implements the Backend interface.
|
||||
func (im *CookieIdentityManager) ApproveScopes(ctx context.Context, sub string, audience string, approvedScopes map[string]bool) (string, error) {
|
||||
ref := rndm.GenerateRandomString(32)
|
||||
|
||||
// TODO(longsleep): Store generated ref with provided data.
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// ApprovedScopes implements the Backend interface.
|
||||
func (im *CookieIdentityManager) ApprovedScopes(ctx context.Context, sub string, audience string, ref string) (map[string]bool, error) {
|
||||
if ref == "" {
|
||||
return nil, fmt.Errorf("SimplePasswdBackend: invalid ref")
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Fetch implements the identity.Manager interface.
|
||||
func (im *CookieIdentityManager) Fetch(ctx context.Context, userID string, sessionRef *string, scopes map[string]bool, requestedClaimsMaps []*payload.ClaimsRequestMap, requestedScopes map[string]bool) (identity.AuthRecord, bool, error) {
|
||||
var user identity.PublicUser
|
||||
|
||||
// Try identty from context.
|
||||
auth, _ := identity.FromContext(ctx)
|
||||
if auth != nil {
|
||||
if auth.User().Raw() != userID {
|
||||
return nil, false, fmt.Errorf("CookieIdentityManager: wrong user - this should not happen")
|
||||
}
|
||||
|
||||
user = auth.User() // This gets the user when added during Authenticate.
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
// Try claims from context.
|
||||
claims, _ := konnect.FromClaimsContext(ctx)
|
||||
if claims != nil {
|
||||
var identityClaimsMap jwt.MapClaims
|
||||
switch c := claims.(type) {
|
||||
case *konnect.AccessTokenClaims:
|
||||
identityClaimsMap = c.IdentityClaims
|
||||
case *konnect.RefreshTokenClaims:
|
||||
identityClaimsMap = c.IdentityClaims
|
||||
case jwt.MapClaims:
|
||||
identityClaimsMap = c
|
||||
default:
|
||||
return nil, false, fmt.Errorf("CookieIdentityManager: unknown identity claims type")
|
||||
}
|
||||
var err error
|
||||
var encodedCookies string
|
||||
encryptedCookies, _ := identityClaimsMap["cookie.v"].(string)
|
||||
if encryptedCookies != "" {
|
||||
encodedCookies, err = im.encryptionManager.DecryptHexToString(encryptedCookies)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("CookieIdentityManager: %v", err)
|
||||
}
|
||||
} else {
|
||||
encodedCookies = encryptedCookies
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
if al, ok := identityClaimsMap["cookie.al"]; ok {
|
||||
headers.Set("Accept-Language", al.(string))
|
||||
}
|
||||
if ua, ok := identityClaimsMap["cookie.ua"]; ok {
|
||||
headers.Set("User-Agent", ua.(string))
|
||||
}
|
||||
user, err = im.backendRequest(ctx, encodedCookies, headers)
|
||||
if err != nil {
|
||||
// Error, directly return.
|
||||
im.logger.WithError(err).Errorln("CookieIdentityManager: backend request error")
|
||||
return nil, false, fmt.Errorf("CookieIdentityManager: backend request error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return nil, false, fmt.Errorf("CookieIdentityManager: no user")
|
||||
}
|
||||
|
||||
if user.Raw() != userID {
|
||||
return nil, false, fmt.Errorf("CookieIdentityManager: wrong user")
|
||||
}
|
||||
|
||||
authorizedScopes, _ := identity.AuthorizeScopes(im, user, scopes)
|
||||
claims := identity.GetUserClaimsForScopes(user, authorizedScopes, requestedClaimsMaps)
|
||||
|
||||
auth = identity.NewAuthRecord(im, user.Subject(), authorizedScopes, nil, claims)
|
||||
auth.SetUser(user)
|
||||
|
||||
return auth, true, nil
|
||||
}
|
||||
|
||||
// Name implements the identity.Manager interface.
|
||||
func (im *CookieIdentityManager) Name() string {
|
||||
return cookieIdentityManagerName
|
||||
}
|
||||
|
||||
// ScopesSupported implements the identity.Manager interface.
|
||||
func (im *CookieIdentityManager) ScopesSupported(scopes map[string]bool) []string {
|
||||
return im.scopesSupported
|
||||
}
|
||||
|
||||
// ClaimsSupported implements the identity.Manager interface.
|
||||
func (im *CookieIdentityManager) ClaimsSupported(claims []string) []string {
|
||||
return []string{
|
||||
oidc.NameClaim,
|
||||
oidc.EmailClaim,
|
||||
oidc.EmailVerifiedClaim,
|
||||
}
|
||||
}
|
||||
|
||||
// AddRoutes implements the identity.Manager interface.
|
||||
func (im *CookieIdentityManager) AddRoutes(ctx context.Context, router *mux.Router) {
|
||||
}
|
||||
|
||||
// OnSetLogon implements the identity.Manager interface.
|
||||
func (im *CookieIdentityManager) OnSetLogon(func(ctx context.Context, rw http.ResponseWriter, user identity.User) error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnUnsetLogon implements the identity.Manager interface.
|
||||
func (im *CookieIdentityManager) OnUnsetLogon(func(ctx context.Context, rw http.ResponseWriter) error) error {
|
||||
return nil
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* 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 managers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/libregraph/oidc-go"
|
||||
"github.com/longsleep/rndm"
|
||||
|
||||
konnect "github.com/libregraph/lico"
|
||||
"github.com/libregraph/lico/identity"
|
||||
|
||||
"github.com/libregraph/lico/oidc/payload"
|
||||
)
|
||||
|
||||
const dummyIdentityManagerName = "dummy"
|
||||
|
||||
// DummyIdentityManager implements an identity manager which always grants
|
||||
// access to a fixed user id.
|
||||
type DummyIdentityManager struct {
|
||||
sub string
|
||||
|
||||
scopesSupported []string
|
||||
}
|
||||
|
||||
// NewDummyIdentityManager creates a new DummyIdentityManager from the
|
||||
// provided parameters.
|
||||
func NewDummyIdentityManager(c *identity.Config, sub string) *DummyIdentityManager {
|
||||
im := &DummyIdentityManager{
|
||||
sub: sub,
|
||||
|
||||
scopesSupported: setupSupportedScopes([]string{
|
||||
oidc.ScopeProfile,
|
||||
oidc.ScopeEmail,
|
||||
}, nil, c.ScopesSupported),
|
||||
}
|
||||
|
||||
return im
|
||||
}
|
||||
|
||||
type dummyUser struct {
|
||||
raw string
|
||||
}
|
||||
|
||||
func (u *dummyUser) Raw() string {
|
||||
return u.raw
|
||||
}
|
||||
|
||||
func (u *dummyUser) Subject() string {
|
||||
sub, _ := getPublicSubject([]byte(u.raw), []byte(dummyIdentityManagerName))
|
||||
return sub
|
||||
}
|
||||
|
||||
func (u *dummyUser) Email() string {
|
||||
return fmt.Sprintf("%s@%s.local", u.raw, u.raw)
|
||||
}
|
||||
|
||||
func (u *dummyUser) EmailVerified() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (u *dummyUser) Name() string {
|
||||
return fmt.Sprintf("Foo %s", strings.Title(u.raw))
|
||||
}
|
||||
|
||||
func (u *dummyUser) Claims() jwt.MapClaims {
|
||||
claims := make(jwt.MapClaims)
|
||||
claims[konnect.IdentifiedUserIDClaim] = u.raw
|
||||
|
||||
return claims
|
||||
}
|
||||
|
||||
// Authenticate implements the identity.Manager interface.
|
||||
func (im *DummyIdentityManager) Authenticate(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, next identity.Manager) (identity.AuthRecord, error) {
|
||||
user := &dummyUser{im.sub}
|
||||
|
||||
// Check request.
|
||||
err := ar.Verify(user.Subject())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
auth := identity.NewAuthRecord(im, user.Subject(), nil, nil, nil)
|
||||
auth.SetUser(user)
|
||||
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// Authorize implements the identity.Manager interface.
|
||||
func (im *DummyIdentityManager) Authorize(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, auth identity.AuthRecord) (identity.AuthRecord, error) {
|
||||
promptConsent := false
|
||||
var approvedScopes map[string]bool
|
||||
|
||||
// Check prompt value.
|
||||
switch {
|
||||
case ar.Prompts[oidc.PromptConsent] == true:
|
||||
promptConsent = true
|
||||
default:
|
||||
// Let all other prompt values pass.
|
||||
}
|
||||
|
||||
// TODO(longsleep): Move the code below to general function.
|
||||
// TODO(longsleep): Validate scopes and force prompt.
|
||||
approvedScopes = ar.Scopes
|
||||
|
||||
// Offline access validation.
|
||||
// http://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
|
||||
if ok, _ := ar.Scopes[oidc.ScopeOfflineAccess]; ok {
|
||||
if !promptConsent {
|
||||
// Ensure that the prompt parameter contains consent unless
|
||||
// other conditions for processing the request permitting offline
|
||||
// access to the requested resources are in place; unless one or
|
||||
// both of these conditions are fulfilled, then it MUST ignore the
|
||||
// offline_access request,
|
||||
delete(ar.Scopes, oidc.ScopeOfflineAccess)
|
||||
}
|
||||
}
|
||||
|
||||
if promptConsent {
|
||||
if ar.Prompts[oidc.PromptNone] == true {
|
||||
return auth, ar.NewError(oidc.ErrorCodeOIDCInteractionRequired, "consent required")
|
||||
}
|
||||
|
||||
// TODO(longsleep): Implement consent page.
|
||||
return auth, ar.NewError(oidc.ErrorCodeOIDCInteractionRequired, "consent required, but page not implemented")
|
||||
}
|
||||
|
||||
auth.AuthorizeScopes(approvedScopes)
|
||||
auth.AuthorizeClaims(ar.Claims)
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// EndSession implements the identity.Manager interface.
|
||||
func (im *DummyIdentityManager) EndSession(ctx context.Context, rw http.ResponseWriter, req *http.Request, esr *payload.EndSessionRequest) error {
|
||||
user := &dummyUser{im.sub}
|
||||
|
||||
err := esr.Verify(user.Subject())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApproveScopes implements the Backend interface.
|
||||
func (im *DummyIdentityManager) ApproveScopes(ctx context.Context, sub string, audience string, approvedScopes map[string]bool) (string, error) {
|
||||
ref := rndm.GenerateRandomString(32)
|
||||
|
||||
// TODO(longsleep): Store generated ref with provided data.
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// ApprovedScopes implements the Backend interface.
|
||||
func (im *DummyIdentityManager) ApprovedScopes(ctx context.Context, sub string, audience string, ref string) (map[string]bool, error) {
|
||||
if ref == "" {
|
||||
return nil, fmt.Errorf("SimplePasswdBackend: invalid ref")
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Fetch implements the identity.Manager interface.
|
||||
func (im *DummyIdentityManager) Fetch(ctx context.Context, userID string, sessionRef *string, scopes map[string]bool, requestedClaimsMaps []*payload.ClaimsRequestMap, requestedScopes map[string]bool) (identity.AuthRecord, bool, error) {
|
||||
if userID != im.sub {
|
||||
return nil, false, fmt.Errorf("DummyIdentityManager: no user")
|
||||
}
|
||||
|
||||
user := &dummyUser{im.sub}
|
||||
|
||||
authorizedScopes, _ := identity.AuthorizeScopes(im, user, scopes)
|
||||
claims := identity.GetUserClaimsForScopes(user, authorizedScopes, requestedClaimsMaps)
|
||||
|
||||
return identity.NewAuthRecord(im, user.Subject(), authorizedScopes, nil, claims), true, nil
|
||||
}
|
||||
|
||||
// Name implements the identity.Manager interface.
|
||||
func (im *DummyIdentityManager) Name() string {
|
||||
return dummyIdentityManagerName
|
||||
}
|
||||
|
||||
// ScopesSupported implements the identity.Manager interface.
|
||||
func (im *DummyIdentityManager) ScopesSupported(scopes map[string]bool) []string {
|
||||
return im.scopesSupported
|
||||
}
|
||||
|
||||
// ClaimsSupported implements the identity.Manager interface.
|
||||
func (im *DummyIdentityManager) ClaimsSupported(claims []string) []string {
|
||||
return []string{
|
||||
oidc.NameClaim,
|
||||
oidc.EmailClaim,
|
||||
oidc.EmailVerifiedClaim,
|
||||
}
|
||||
}
|
||||
|
||||
// AddRoutes implements the identity.Manager interface.
|
||||
func (im *DummyIdentityManager) AddRoutes(ctx context.Context, router *mux.Router) {
|
||||
}
|
||||
|
||||
// OnSetLogon implements the identity.Manager interface.
|
||||
func (im *DummyIdentityManager) OnSetLogon(func(ctx context.Context, rw http.ResponseWriter, user identity.User) error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnUnsetLogon implements the identity.Manager interface.
|
||||
func (im *DummyIdentityManager) OnUnsetLogon(func(ctx context.Context, rw http.ResponseWriter) error) error {
|
||||
return nil
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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 managers
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"github.com/libregraph/lico/encryption"
|
||||
)
|
||||
|
||||
// EncryptionManager implements string encryption functions with a key.
|
||||
type EncryptionManager struct {
|
||||
key *[encryption.KeySize]byte
|
||||
}
|
||||
|
||||
// NewEncryptionManager creates a new EncryptionManager with the provided key.
|
||||
func NewEncryptionManager(key *[encryption.KeySize]byte) (*EncryptionManager, error) {
|
||||
em := &EncryptionManager{
|
||||
key: key,
|
||||
}
|
||||
|
||||
return em, nil
|
||||
}
|
||||
|
||||
// SetKey sets the provided key for the accociated manager.
|
||||
func (em *EncryptionManager) SetKey(key []byte) error {
|
||||
switch len(key) {
|
||||
case encryption.KeySize:
|
||||
// all good, breaks
|
||||
case hex.EncodedLen(encryption.KeySize):
|
||||
// try to decode with hex
|
||||
dst := make([]byte, encryption.KeySize)
|
||||
if _, err := hex.Decode(dst, key); err == nil {
|
||||
key = dst
|
||||
}
|
||||
}
|
||||
if len(key) != encryption.KeySize {
|
||||
return fmt.Errorf("encryption key size error, is %d, want %d", len(key), encryption.KeySize)
|
||||
}
|
||||
|
||||
em.key = new([encryption.KeySize]byte)
|
||||
copy(em.key[:], key[:encryption.KeySize])
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetKeySize returns the size of the accociated manager's key.
|
||||
func (em *EncryptionManager) GetKeySize() int {
|
||||
return len(em.key)
|
||||
}
|
||||
|
||||
// EncryptStringToHexString encrypts a plaintext string with the accociated
|
||||
// key and returns the hex encoded ciphertext as string.
|
||||
func (em *EncryptionManager) EncryptStringToHexString(plaintext string) (string, error) {
|
||||
ciphertext, err := em.Encrypt([]byte(plaintext))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return hex.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts plaintext []byte with the accociated key and returns
|
||||
// ciphertext []byte.
|
||||
func (em *EncryptionManager) Encrypt(plaintext []byte) ([]byte, error) {
|
||||
ciphertext, err := encryption.Encrypt(plaintext, em.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
// DecryptHexToString decrypts a hex encoded string with the accociated key
|
||||
// and returns the plain text as string.
|
||||
func (em *EncryptionManager) DecryptHexToString(ciphertextHex string) (string, error) {
|
||||
ciphertext, err := hex.DecodeString(ciphertextHex)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plaintext, err := em.Decrypt(ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts ciphertext []byte with the accociated key and returns
|
||||
// plaintext []byte.
|
||||
func (em *EncryptionManager) Decrypt(ciphertext []byte) ([]byte, error) {
|
||||
plaintext, err := encryption.Decrypt(ciphertext, em.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
+503
@@ -0,0 +1,503 @@
|
||||
/*
|
||||
* 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 managers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/libregraph/oidc-go"
|
||||
"github.com/longsleep/rndm"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
konnect "github.com/libregraph/lico"
|
||||
"github.com/libregraph/lico/identity"
|
||||
"github.com/libregraph/lico/identity/clients"
|
||||
"github.com/libregraph/lico/managers"
|
||||
"github.com/libregraph/lico/oidc/payload"
|
||||
"github.com/libregraph/lico/utils"
|
||||
)
|
||||
|
||||
const guestIdentitityManagerName = "guest"
|
||||
|
||||
// GuestIdentityManager implements an identity manager for guest users.
|
||||
type GuestIdentityManager struct {
|
||||
scopesSupported []string
|
||||
claimsSupported []string
|
||||
|
||||
logger logrus.FieldLogger
|
||||
clients *clients.Registry
|
||||
|
||||
onSetLogonCallbacks []func(ctx context.Context, rw http.ResponseWriter, user identity.User) error
|
||||
onUnsetLogonCallbacks []func(ctx context.Context, rw http.ResponseWriter) error
|
||||
}
|
||||
|
||||
// NewGuestIdentityManager creates a new GuestIdentityManager from the
|
||||
// provided parameters.
|
||||
func NewGuestIdentityManager(c *identity.Config) *GuestIdentityManager {
|
||||
im := &GuestIdentityManager{
|
||||
scopesSupported: setupSupportedScopes([]string{}, []string{
|
||||
konnect.ScopeNumericID,
|
||||
oidc.ScopeProfile,
|
||||
oidc.ScopeEmail,
|
||||
}, c.ScopesSupported),
|
||||
claimsSupported: []string{
|
||||
oidc.NameClaim,
|
||||
oidc.FamilyNameClaim,
|
||||
oidc.GivenNameClaim,
|
||||
oidc.EmailClaim,
|
||||
oidc.EmailVerifiedClaim,
|
||||
},
|
||||
|
||||
logger: c.Logger,
|
||||
|
||||
onSetLogonCallbacks: make([]func(ctx context.Context, rw http.ResponseWriter, user identity.User) error, 0),
|
||||
onUnsetLogonCallbacks: make([]func(ctx context.Context, rw http.ResponseWriter) error, 0),
|
||||
}
|
||||
|
||||
return im
|
||||
}
|
||||
|
||||
type guestUser struct {
|
||||
raw string
|
||||
email string
|
||||
emailVerified bool
|
||||
name string
|
||||
familyName string
|
||||
givenName string
|
||||
}
|
||||
|
||||
func newGuestUserFromClaims(claims jwt.MapClaims) *guestUser {
|
||||
isGuestClaim, ok := claims[konnect.IdentifiedUserIsGuest]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
isGuest, _ := isGuestClaim.(bool)
|
||||
if !isGuest {
|
||||
return nil
|
||||
}
|
||||
|
||||
idClaim, ok := claims[konnect.IdentifiedUserIDClaim]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
dataClaim, ok := claims[konnect.IdentifiedData]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
user := &guestUser{
|
||||
raw: idClaim.(string),
|
||||
}
|
||||
data, _ := dataClaim.(map[string]interface{})
|
||||
for name, value := range data {
|
||||
switch name {
|
||||
case "e":
|
||||
user.email, _ = value.(string)
|
||||
case "ev":
|
||||
if v, _ := value.(int); v == 1 {
|
||||
user.emailVerified = true
|
||||
}
|
||||
case "n":
|
||||
user.name, _ = value.(string)
|
||||
case "nf":
|
||||
user.familyName, _ = value.(string)
|
||||
case "ng":
|
||||
user.givenName, _ = value.(string)
|
||||
}
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
type minimalGuestUserData struct {
|
||||
E string `json:"e,omitempty"`
|
||||
EV int `json:"ev,omitempty"`
|
||||
N string `json:"n,omitempty"`
|
||||
NF string `json:"nf,omitempty"`
|
||||
NG string `json:"ng,omitempty"`
|
||||
}
|
||||
|
||||
func (u *guestUser) Raw() string {
|
||||
return u.raw
|
||||
}
|
||||
|
||||
func (u *guestUser) Subject() string {
|
||||
sub, _ := getPublicSubject([]byte(u.raw), []byte(guestIdentitityManagerName))
|
||||
return sub
|
||||
}
|
||||
|
||||
func (u *guestUser) Email() string {
|
||||
return u.email
|
||||
}
|
||||
|
||||
func (u *guestUser) EmailVerified() bool {
|
||||
return u.emailVerified
|
||||
}
|
||||
|
||||
func (u *guestUser) Name() string {
|
||||
return u.name
|
||||
}
|
||||
|
||||
func (u *guestUser) FamilyName() string {
|
||||
return u.familyName
|
||||
}
|
||||
|
||||
func (u *guestUser) GivenName() string {
|
||||
return u.givenName
|
||||
}
|
||||
|
||||
func (u *guestUser) Claims() jwt.MapClaims {
|
||||
claims := make(jwt.MapClaims)
|
||||
claims[konnect.IdentifiedUserIDClaim] = u.raw
|
||||
claims[konnect.IdentifiedUserIsGuest] = true
|
||||
|
||||
m := &minimalGuestUserData{
|
||||
E: u.email,
|
||||
N: u.name,
|
||||
NF: u.familyName,
|
||||
NG: u.givenName,
|
||||
}
|
||||
if u.emailVerified {
|
||||
m.EV = 1
|
||||
}
|
||||
claims[konnect.IdentifiedData] = m
|
||||
|
||||
return claims
|
||||
}
|
||||
|
||||
// RegisterManagers registers the provided managers,
|
||||
func (im *GuestIdentityManager) RegisterManagers(mgrs *managers.Managers) error {
|
||||
im.clients = mgrs.Must("clients").(*clients.Registry)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Authenticate implements the identity.Manager interface.
|
||||
func (im *GuestIdentityManager) Authenticate(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, next identity.Manager) (identity.AuthRecord, error) {
|
||||
// Check if required scopes are there.
|
||||
if !ar.Scopes[konnect.ScopeGuestOK] {
|
||||
return nil, ar.NewError(oidc.ErrorCodeOIDCLoginRequired, "GuestIdentityManager: required scope missing")
|
||||
}
|
||||
|
||||
// Authenticate with signed client request object, so that must be there.
|
||||
if ar.Request == nil {
|
||||
return nil, ar.NewError(oidc.ErrorCodeOIDCInvalidRequestObject, "GuestIdentityManager: no request object")
|
||||
}
|
||||
|
||||
// Further checks of signed claims.
|
||||
roc, ok := ar.Request.Claims.(*payload.RequestObjectClaims)
|
||||
if !ok {
|
||||
return nil, ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "GuestIdentityManager: invalid claims request")
|
||||
}
|
||||
|
||||
// NOTE(longsleep): Require claims in request object to ensure that the
|
||||
// claims requested come from there.
|
||||
if roc.Claims == nil || ar.Claims == nil {
|
||||
return nil, ar.NewError(oidc.ErrorCodeOAuth2InvalidRequest, "GuestIdentityManager: missing claims request")
|
||||
}
|
||||
// NOTE(longsleep): Guest mode requires ID token claims request with the
|
||||
// guest claim set to an expected value.
|
||||
if ar.Claims.IDToken == nil {
|
||||
return nil, ar.NewError(oidc.ErrorCodeOAuth2InvalidRequest, "GuestIdentityManager: missing claims request for id_token")
|
||||
}
|
||||
guest, ok := ar.Claims.IDToken.GetStringValue("guest")
|
||||
if !ok {
|
||||
return nil, ar.NewError(oidc.ErrorCodeOAuth2InvalidRequest, "GuestIdentityManager: missing claim guest in id_token claims request")
|
||||
}
|
||||
|
||||
// Ensure that request object claim is signed.
|
||||
if ar.Request.Method == jwt.SigningMethodNone {
|
||||
return nil, ar.NewBadRequest(oidc.ErrorCodeOIDCInvalidRequestObject, "GuestIdentityManager: request object must be signed")
|
||||
}
|
||||
|
||||
if guest == "" {
|
||||
return nil, ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "GuestIdentityManager: invalid claim guest in id_token claims request")
|
||||
}
|
||||
|
||||
// Additional email and profile claim values will be taken over into the
|
||||
// guest user data.
|
||||
email, _ := ar.Claims.IDToken.GetStringValue(oidc.EmailClaim)
|
||||
var emailVerified bool
|
||||
if emailVerifiedRaw, ok := ar.Claims.IDToken.Get(oidc.EmailVerifiedClaim); ok {
|
||||
emailVerified, _ = emailVerifiedRaw.Value.(bool)
|
||||
}
|
||||
|
||||
name, _ := ar.Claims.IDToken.GetStringValue(oidc.NameClaim)
|
||||
familyName, _ := ar.Claims.IDToken.GetStringValue(oidc.FamilyNameClaim)
|
||||
givenName, _ := ar.Claims.IDToken.GetStringValue(oidc.GivenNameClaim)
|
||||
|
||||
// Make new user with the provided signed information.
|
||||
sub := guest
|
||||
user := &guestUser{
|
||||
raw: sub,
|
||||
email: email,
|
||||
emailVerified: emailVerified,
|
||||
name: name,
|
||||
familyName: familyName,
|
||||
givenName: givenName,
|
||||
}
|
||||
|
||||
// TODO(longsleep): Add additional claims to user from the claims request
|
||||
// after filtering.
|
||||
|
||||
// Check request.
|
||||
err := ar.Verify(user.Subject())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
auth := identity.NewAuthRecord(im, user.Subject(), nil, nil, nil)
|
||||
auth.SetUser(user)
|
||||
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// Authorize implements the identity.Manager interface.
|
||||
func (im *GuestIdentityManager) Authorize(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, auth identity.AuthRecord) (identity.AuthRecord, error) {
|
||||
promptConsent := false
|
||||
var approvedScopes map[string]bool
|
||||
|
||||
// Check prompt value.
|
||||
switch {
|
||||
case ar.Prompts[oidc.PromptConsent] == true:
|
||||
promptConsent = true
|
||||
default:
|
||||
// Let all other prompt values pass.
|
||||
}
|
||||
|
||||
// Offline access validation.
|
||||
// http://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
|
||||
if ok, _ := ar.Scopes[oidc.ScopeOfflineAccess]; ok {
|
||||
if !promptConsent {
|
||||
// Ensure that the prompt parameter contains consent unless
|
||||
// other conditions for processing the request permitting offline
|
||||
// access to the requested resources are in place; unless one or
|
||||
// both of these conditions are fulfilled, then it MUST ignore the
|
||||
// offline_access request,
|
||||
delete(ar.Scopes, oidc.ScopeOfflineAccess)
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate with signed client request object, so that must be there.
|
||||
if ar.Request == nil {
|
||||
return nil, ar.NewError(oidc.ErrorCodeOIDCInvalidRequestObject, "GuestIdentityManager: authorize without request object")
|
||||
}
|
||||
|
||||
// Further checks of signed claims.
|
||||
roc, ok := ar.Request.Claims.(*payload.RequestObjectClaims)
|
||||
if !ok {
|
||||
return nil, ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "GuestIdentityManager: authorize with invalid claims request")
|
||||
}
|
||||
|
||||
securedDetails := roc.Secure()
|
||||
if securedDetails == nil {
|
||||
return nil, ar.NewBadRequest(oidc.ErrorCodeOIDCInvalidRequestObject, "GuestIdentityManager: authorize without secure client")
|
||||
}
|
||||
|
||||
// TODO(longsleep): Validate scopes and force prompt.
|
||||
|
||||
if promptConsent {
|
||||
if ar.Prompts[oidc.PromptNone] == true {
|
||||
return auth, ar.NewError(oidc.ErrorCodeOIDCInteractionRequired, "consent required")
|
||||
}
|
||||
|
||||
// TODO(longsleep): Implement consent page.
|
||||
return auth, ar.NewError(oidc.ErrorCodeOIDCInteractionRequired, "consent required, but not supported for guests")
|
||||
}
|
||||
|
||||
origin := ""
|
||||
if false {
|
||||
// TODO(longsleep): find a condition when this can be enabled.
|
||||
origin = utils.OriginFromRequestHeaders(req.Header)
|
||||
}
|
||||
|
||||
clientDetails, err := im.clients.Lookup(req.Context(), ar.ClientID, "", ar.RedirectURI, origin, true)
|
||||
if err != nil {
|
||||
return nil, ar.NewError(oidc.ErrorCodeOAuth2AccessDenied, err.Error())
|
||||
}
|
||||
|
||||
if clientDetails.ID != securedDetails.ID {
|
||||
return nil, ar.NewError(oidc.ErrorCodeOAuth2AccessDenied, "client mismatch")
|
||||
}
|
||||
|
||||
// If not trusted we need to check request scopes.
|
||||
if clientDetails.Trusted && securedDetails.TrustedScopes == nil {
|
||||
// NOTE(longsleep): Guest scope validation takes all client provided
|
||||
// scopes when the trusted client configuration has no trusted scopes
|
||||
// configured. This can be used for fine grained access control using
|
||||
// the trusted client configuration.
|
||||
approvedScopes = ar.Scopes
|
||||
} else {
|
||||
supportedScopes := make(map[string]bool)
|
||||
for _, scope := range im.ScopesSupported(nil) {
|
||||
supportedScopes[scope] = true
|
||||
}
|
||||
|
||||
// Auto approve all supported scopes.
|
||||
approvedScopes = make(map[string]bool)
|
||||
for scope := range ar.Scopes {
|
||||
if _, ok := supportedScopes[scope]; ok {
|
||||
approvedScopes[scope] = true
|
||||
}
|
||||
}
|
||||
// Approve all additional scopes which are allowed by the trusted
|
||||
// client.
|
||||
for _, scope := range securedDetails.TrustedScopes {
|
||||
if _, ok := ar.Scopes[scope]; ok {
|
||||
approvedScopes[scope] = true
|
||||
}
|
||||
}
|
||||
// Always approve openid scope.
|
||||
if _, ok := ar.Scopes[oidc.ScopeOpenID]; ok {
|
||||
approvedScopes[oidc.ScopeOpenID] = true
|
||||
}
|
||||
|
||||
// Ensure that guest scope was approved.
|
||||
if ok, _ := approvedScopes[konnect.ScopeGuestOK]; !ok {
|
||||
return nil, ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "GuestIdentityManager: client does not authorize "+konnect.ScopeGuestOK+" scope")
|
||||
}
|
||||
}
|
||||
|
||||
auth.AuthorizeScopes(approvedScopes)
|
||||
auth.AuthorizeClaims(ar.Claims)
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// EndSession implements the identity.Manager interface.
|
||||
func (im *GuestIdentityManager) EndSession(ctx context.Context, rw http.ResponseWriter, req *http.Request, esr *payload.EndSessionRequest) error {
|
||||
// TODO(longsleep): Implement end session for guests.
|
||||
|
||||
// Trigger callbacks.
|
||||
for _, f := range im.onUnsetLogonCallbacks {
|
||||
err := f(ctx, rw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApproveScopes implements the Backend interface.
|
||||
func (im *GuestIdentityManager) ApproveScopes(ctx context.Context, sub string, audience string, approvedScopes map[string]bool) (string, error) {
|
||||
ref := rndm.GenerateRandomString(32)
|
||||
|
||||
// TODO(longsleep): Store generated ref with provided data.
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// ApprovedScopes implements the Backend interface.
|
||||
func (im *GuestIdentityManager) ApprovedScopes(ctx context.Context, sub string, audience string, ref string) (map[string]bool, error) {
|
||||
if ref == "" {
|
||||
return nil, fmt.Errorf("GuestIdentityManager: invalid ref")
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Fetch implements the identity.Manager interface.
|
||||
func (im *GuestIdentityManager) Fetch(ctx context.Context, userID string, sessionRef *string, scopes map[string]bool, requestedClaimsMaps []*payload.ClaimsRequestMap, requestedScopes map[string]bool) (identity.AuthRecord, bool, error) {
|
||||
var user identity.PublicUser
|
||||
|
||||
for {
|
||||
// First check if current context has auth.
|
||||
if auth, ok := identity.FromContext(ctx); ok {
|
||||
user = auth.User()
|
||||
break
|
||||
}
|
||||
// Second check if current context has claims with guest identity in it.
|
||||
if claims, ok := konnect.FromClaimsContext(ctx); ok {
|
||||
var identityClaims jwt.MapClaims
|
||||
var identityProvider string
|
||||
switch c := claims.(type) {
|
||||
case *konnect.AccessTokenClaims:
|
||||
identityClaims = c.IdentityClaims
|
||||
identityProvider = c.IdentityProvider
|
||||
case *konnect.RefreshTokenClaims:
|
||||
identityClaims = c.IdentityClaims
|
||||
identityProvider = c.IdentityProvider
|
||||
}
|
||||
if identityClaims != nil && identityProvider == im.Name() {
|
||||
user = newGuestUserFromClaims(identityClaims)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false, fmt.Errorf("GuestIdentityManager: no user in context")
|
||||
}
|
||||
|
||||
if user.Raw() != userID {
|
||||
return nil, false, fmt.Errorf("GuestIdentityManager: wrong user")
|
||||
}
|
||||
|
||||
authorizedScopes, _ := identity.AuthorizeScopes(im, user, scopes)
|
||||
claims := identity.GetUserClaimsForScopes(user, authorizedScopes, requestedClaimsMaps)
|
||||
|
||||
auth := identity.NewAuthRecord(im, user.Subject(), authorizedScopes, nil, claims)
|
||||
auth.SetUser(user)
|
||||
|
||||
return auth, true, nil
|
||||
}
|
||||
|
||||
// Name implements the identity.Manager interface.
|
||||
func (im *GuestIdentityManager) Name() string {
|
||||
return guestIdentitityManagerName
|
||||
}
|
||||
|
||||
// ScopesSupported implements the identity.Manager interface.
|
||||
func (im *GuestIdentityManager) ScopesSupported(scopes map[string]bool) []string {
|
||||
if scopes != nil {
|
||||
// NOTE(longsleep): Allow scopes as we get them, since we already validated
|
||||
// them in authorize.
|
||||
supported := make([]string, 0)
|
||||
for scope, ok := range scopes {
|
||||
if ok {
|
||||
supported = append(supported, scope)
|
||||
}
|
||||
}
|
||||
return supported
|
||||
}
|
||||
|
||||
return im.scopesSupported
|
||||
}
|
||||
|
||||
// ClaimsSupported implements the identity.Manager interface.
|
||||
func (im *GuestIdentityManager) ClaimsSupported(claims []string) []string {
|
||||
return im.claimsSupported
|
||||
}
|
||||
|
||||
// AddRoutes implements the identity.Manager interface.
|
||||
func (im *GuestIdentityManager) AddRoutes(ctx context.Context, router *mux.Router) {
|
||||
}
|
||||
|
||||
// OnSetLogon implements the identity.Manager interface.
|
||||
func (im *GuestIdentityManager) OnSetLogon(cb func(ctx context.Context, rw http.ResponseWriter, user identity.User) error) error {
|
||||
im.onSetLogonCallbacks = append(im.onSetLogonCallbacks, cb)
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnUnsetLogon implements the identity.Manager interface.
|
||||
func (im *GuestIdentityManager) OnUnsetLogon(cb func(ctx context.Context, rw http.ResponseWriter) error) error {
|
||||
im.onUnsetLogonCallbacks = append(im.onUnsetLogonCallbacks, cb)
|
||||
return nil
|
||||
}
|
||||
+562
@@ -0,0 +1,562 @@
|
||||
/*
|
||||
* 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 managers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/libregraph/oidc-go"
|
||||
"github.com/longsleep/rndm"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/libregraph/lico/identifier"
|
||||
"github.com/libregraph/lico/identity"
|
||||
"github.com/libregraph/lico/identity/clients"
|
||||
"github.com/libregraph/lico/managers"
|
||||
konnectoidc "github.com/libregraph/lico/oidc"
|
||||
"github.com/libregraph/lico/oidc/payload"
|
||||
"github.com/libregraph/lico/utils"
|
||||
)
|
||||
|
||||
// IdentifierIdentityManager implements an identity manager which relies on
|
||||
// Konnect its identifier to provide identity.
|
||||
type IdentifierIdentityManager struct {
|
||||
signInFormURI string
|
||||
signedOutURI string
|
||||
|
||||
scopesSupported []string
|
||||
claimsSupported []string
|
||||
|
||||
identifier *identifier.Identifier
|
||||
clients *clients.Registry
|
||||
logger logrus.FieldLogger
|
||||
}
|
||||
|
||||
type identifierUser struct {
|
||||
*identifier.IdentifiedUser
|
||||
}
|
||||
|
||||
func (u *identifierUser) Raw() string {
|
||||
return u.IdentifiedUser.Subject()
|
||||
}
|
||||
|
||||
func (u *identifierUser) Subject() string {
|
||||
sub, _ := getPublicSubject([]byte(u.Raw()), []byte(u.IdentifiedUser.BackendName()))
|
||||
return sub
|
||||
}
|
||||
|
||||
func (u *identifierUser) Scopes() []string {
|
||||
return u.IdentifiedUser.Scopes()
|
||||
}
|
||||
|
||||
func (u *identifierUser) RequiredScopes() map[string]bool {
|
||||
lockedScopes := u.IdentifiedUser.LockedScopes()
|
||||
if lockedScopes == nil {
|
||||
return nil
|
||||
}
|
||||
requiredScopes := make(map[string]bool)
|
||||
for _, scope := range lockedScopes {
|
||||
if strings.HasPrefix(scope, "!") {
|
||||
scope = strings.TrimLeft(scope, "!")
|
||||
requiredScopes[scope] = false
|
||||
} else {
|
||||
requiredScopes[scope] = true
|
||||
}
|
||||
}
|
||||
return requiredScopes
|
||||
}
|
||||
|
||||
func asIdentifierUser(user *identifier.IdentifiedUser) *identifierUser {
|
||||
return &identifierUser{user}
|
||||
}
|
||||
|
||||
// NewIdentifierIdentityManager creates a new IdentifierIdentityManager from the provided
|
||||
// parameters.
|
||||
func NewIdentifierIdentityManager(c *identity.Config, i *identifier.Identifier) *IdentifierIdentityManager {
|
||||
im := &IdentifierIdentityManager{
|
||||
signInFormURI: c.SignInFormURI.String(),
|
||||
signedOutURI: c.SignedOutURI.String(),
|
||||
|
||||
scopesSupported: setupSupportedScopes([]string{
|
||||
oidc.ScopeOfflineAccess,
|
||||
}, nil, c.ScopesSupported),
|
||||
claimsSupported: []string{
|
||||
oidc.NameClaim,
|
||||
oidc.FamilyNameClaim,
|
||||
oidc.GivenNameClaim,
|
||||
oidc.EmailClaim,
|
||||
oidc.EmailVerifiedClaim,
|
||||
},
|
||||
|
||||
identifier: i,
|
||||
logger: c.Logger,
|
||||
}
|
||||
|
||||
return im
|
||||
}
|
||||
|
||||
// RegisterManagers registers the provided managers,
|
||||
func (im *IdentifierIdentityManager) RegisterManagers(mgrs *managers.Managers) error {
|
||||
im.clients = mgrs.Must("clients").(*clients.Registry)
|
||||
|
||||
return im.identifier.RegisterManagers(mgrs)
|
||||
}
|
||||
|
||||
// Authenticate implements the identity.Manager interface.
|
||||
func (im *IdentifierIdentityManager) Authenticate(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, next identity.Manager) (identity.AuthRecord, error) {
|
||||
var user *identifierUser
|
||||
var err error
|
||||
|
||||
if authenticationErrorID := req.Form.Get("error"); authenticationErrorID != "" {
|
||||
// Incoming with error. Directly abort and return.
|
||||
return nil, ar.NewError(authenticationErrorID, req.Form.Get("error_description"))
|
||||
}
|
||||
|
||||
u, _ := im.identifier.GetUserFromLogonCookie(ctx, req, ar.MaxAge, true)
|
||||
if u != nil {
|
||||
// TODO(longsleep): Add other user meta data.
|
||||
user = asIdentifierUser(u)
|
||||
} else {
|
||||
// Not signed in.
|
||||
if mode := req.Form.Get("identifier"); mode == identifier.MustBeSignedIn {
|
||||
// Identifier mode is set to must, this means that this flow must be authenticated here, and everything
|
||||
// else is an error. This is for example set, when coming back from an external authority.
|
||||
im.logger.WithField("mode", mode).Debugln("identifier mode is set, but not signed in")
|
||||
} else if next != nil {
|
||||
// Give next handler a chance if any.
|
||||
if auth, authErr := next.Authenticate(ctx, rw, req, ar, nil); authErr == nil {
|
||||
// Inner handler success.
|
||||
// TODO(longsleep): Add check and option to avoid that the inner
|
||||
// handler can ever return users which exist at the outer.
|
||||
return auth, authErr
|
||||
} else {
|
||||
switch authErr.(type) {
|
||||
case *payload.AuthenticationError:
|
||||
// ignore, breaks
|
||||
case *identity.LoginRequiredError:
|
||||
// ignore, breaks
|
||||
case *identity.IsHandledError:
|
||||
// breaks, breaks
|
||||
default:
|
||||
im.logger.WithFields(utils.ErrorAsFields(authErr)).Errorln("inner authorize request failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
err = ar.NewError(oidc.ErrorCodeOIDCLoginRequired, "IdentifierIdentityManager: not signed in")
|
||||
}
|
||||
|
||||
// Check prompt value.
|
||||
switch {
|
||||
case ar.Prompts[oidc.PromptNone] == true:
|
||||
if err != nil {
|
||||
// Never show sign-in, directly return error.
|
||||
return nil, err
|
||||
}
|
||||
case ar.Prompts[oidc.PromptLogin] == true:
|
||||
if err == nil {
|
||||
// Enforce to show sign-in, when signed in.
|
||||
err = ar.NewError(oidc.ErrorCodeOIDCLoginRequired, "IdentifierIdentityManager: prompt=login request")
|
||||
}
|
||||
case ar.Prompts[oidc.PromptSelectAccount] == true:
|
||||
if err == nil {
|
||||
// Enforce to show sign-in, when signed in.
|
||||
err = ar.NewError(oidc.ErrorCodeOIDCLoginRequired, "IdentifierIdentityManager: prompt=select_account request")
|
||||
}
|
||||
default:
|
||||
// Let all other prompt values pass.
|
||||
}
|
||||
|
||||
var auth identity.AuthRecord
|
||||
|
||||
// More checks.
|
||||
if err == nil {
|
||||
var sub string
|
||||
if user != nil {
|
||||
sub = user.Subject()
|
||||
}
|
||||
err = ar.Verify(sub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
record := identifier.NewRecord(req, im.identifier.Config.Config)
|
||||
record.IdentifiedUser = user.IdentifiedUser
|
||||
ctx = identifier.NewRecordContext(ctx, record)
|
||||
|
||||
// Inject required scopes into request.
|
||||
for scope, ok := range user.RequiredScopes() {
|
||||
ar.Scopes[scope] = ok
|
||||
}
|
||||
// Load user record from identitymanager, without any scopes or claims
|
||||
// to ensure that the user data is refreshed and that the user still
|
||||
// exists.
|
||||
var found bool
|
||||
auth, found, err = im.Fetch(ctx, user.Raw(), user.SessionRef(), nil, nil, ar.Scopes)
|
||||
if !found {
|
||||
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2ServerError, "user not found")
|
||||
} else {
|
||||
// Update ar.Scopes with the ones gotten from backend.
|
||||
if bu, ok := auth.User().(*identifierUser); ok {
|
||||
scopes := bu.Scopes()
|
||||
if scopes != nil {
|
||||
expanded := make(map[string]bool)
|
||||
for _, scope := range scopes {
|
||||
if enabled, ok := ar.Scopes[scope]; ok && !enabled {
|
||||
// Skip already known but not enabled scopes.
|
||||
continue
|
||||
}
|
||||
expanded[scope] = true
|
||||
}
|
||||
ar.Scopes = expanded
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if ar.Prompts[oidc.PromptNone] == true {
|
||||
// Never show sign-in, directly return error.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build login URL.
|
||||
query, err := url.ParseQuery(req.URL.RawQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query.Set("flow", identifier.FlowOIDC)
|
||||
if ar.Claims != nil {
|
||||
// Add derived scope list from claims request.
|
||||
claimsScopes := ar.Claims.Scopes(ar.Scopes)
|
||||
if len(claimsScopes) > 0 {
|
||||
query.Set("claims_scope", strings.Join(claimsScopes, " "))
|
||||
}
|
||||
}
|
||||
u, _ := url.Parse(im.signInFormURI)
|
||||
u.RawQuery = query.Encode()
|
||||
utils.WriteRedirect(rw, http.StatusFound, u, nil, false)
|
||||
|
||||
return nil, &identity.IsHandledError{}
|
||||
}
|
||||
|
||||
if auth == nil {
|
||||
// In case no existing user was fetched and that was not an error, make
|
||||
// sure that we actually create a new auth record. This should not
|
||||
// happen and is kept here for potential backwards compatibility.
|
||||
auth = identity.NewAuthRecord(im, user.Subject(), nil, nil, nil)
|
||||
auth.SetUser(user)
|
||||
}
|
||||
|
||||
if loggedOn, logonAt := u.LoggedOn(); loggedOn {
|
||||
auth.SetAuthTime(logonAt)
|
||||
}
|
||||
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// Authorize implements the identity.Manager interface.
|
||||
func (im *IdentifierIdentityManager) Authorize(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, auth identity.AuthRecord) (identity.AuthRecord, error) {
|
||||
promptConsent := false
|
||||
var approvedScopes map[string]bool
|
||||
|
||||
// Check prompt value.
|
||||
switch {
|
||||
case ar.Prompts[oidc.PromptConsent] == true:
|
||||
promptConsent = true
|
||||
default:
|
||||
// Let all other prompt values pass.
|
||||
}
|
||||
|
||||
origin := ""
|
||||
if false {
|
||||
// TODO(longsleep): find a condition when this can be enabled.
|
||||
origin = utils.OriginFromRequestHeaders(req.Header)
|
||||
}
|
||||
clientDetails, err := im.clients.Lookup(req.Context(), ar.ClientID, "", ar.RedirectURI, origin, true)
|
||||
if err != nil {
|
||||
return nil, ar.NewError(oidc.ErrorCodeOAuth2AccessDenied, err.Error())
|
||||
}
|
||||
|
||||
// If not trusted, always force consent.
|
||||
if clientDetails.Trusted {
|
||||
approvedScopes = ar.Scopes
|
||||
} else {
|
||||
promptConsent = true
|
||||
}
|
||||
|
||||
// Check given consent.
|
||||
consent, err := im.identifier.GetConsentFromConsentCookie(req.Context(), rw, req, req.Form.Get("konnect"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if consent != nil {
|
||||
if !consent.Allow {
|
||||
return auth, ar.NewError(oidc.ErrorCodeOAuth2AccessDenied, "consent denied")
|
||||
}
|
||||
|
||||
promptConsent = false
|
||||
filteredApprovedScopes, allApprovedScopes := consent.Scopes(ar.Scopes)
|
||||
|
||||
// Filter claims request by approved scopes.
|
||||
if ar.Claims != nil {
|
||||
err = ar.Claims.ApplyScopes(allApprovedScopes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
approvedScopes = filteredApprovedScopes
|
||||
}
|
||||
|
||||
if promptConsent {
|
||||
if ar.Prompts[oidc.PromptNone] == true {
|
||||
return auth, ar.NewError(oidc.ErrorCodeOIDCInteractionRequired, "consent required")
|
||||
}
|
||||
|
||||
// Build consent URL.
|
||||
query, err := url.ParseQuery(req.URL.RawQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query.Set("flow", identifier.FlowConsent)
|
||||
if ar.Claims != nil {
|
||||
// Add derived scope list from claims request.
|
||||
claimsScopes := ar.Claims.Scopes(ar.Scopes)
|
||||
if len(claimsScopes) > 0 {
|
||||
query.Set("claims_scope", strings.Join(claimsScopes, " "))
|
||||
}
|
||||
}
|
||||
if ar.Scopes != nil {
|
||||
scopes := make([]string, 0)
|
||||
for scope, ok := range ar.Scopes {
|
||||
if ok {
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
query.Set("scope", strings.Join(scopes, " "))
|
||||
}
|
||||
}
|
||||
u, _ := url.Parse(im.signInFormURI)
|
||||
u.RawQuery = query.Encode()
|
||||
utils.WriteRedirect(rw, http.StatusFound, u, nil, false)
|
||||
|
||||
return nil, &identity.IsHandledError{}
|
||||
}
|
||||
|
||||
// Offline access validation.
|
||||
// http://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
|
||||
if ok, _ := approvedScopes[oidc.ScopeOfflineAccess]; ok {
|
||||
var ignoreOfflineAccessErr error
|
||||
for {
|
||||
if ok, _ := ar.ResponseTypes[oidc.ResponseTypeCode]; !ok {
|
||||
// MUST ignore the offline_access request unless the Client is using
|
||||
// a response_type value that would result in an Authorization
|
||||
// Code being returned,
|
||||
ignoreOfflineAccessErr = fmt.Errorf("response_type=code required, %#v", ar.ResponseTypes)
|
||||
break
|
||||
}
|
||||
|
||||
if clientDetails.Trusted {
|
||||
// Always allow offline access for trusted clients. This qualifies
|
||||
// for other conditions.
|
||||
break
|
||||
}
|
||||
|
||||
if ok, _ := ar.Prompts[oidc.PromptConsent]; !ok && consent == nil {
|
||||
// Ensure that the prompt parameter contains consent unless
|
||||
// other conditions for processing the request permitting offline
|
||||
// access to the requested resources are in place; unless one or
|
||||
// both of these conditions are fulfilled, then it MUST ignore the
|
||||
// offline_access request,
|
||||
ignoreOfflineAccessErr = fmt.Errorf("prompt=consent required, %#v", ar.Prompts)
|
||||
break
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if ignoreOfflineAccessErr != nil {
|
||||
delete(approvedScopes, oidc.ScopeOfflineAccess)
|
||||
im.logger.WithError(ignoreOfflineAccessErr).Debugln("removed offline_access scope")
|
||||
}
|
||||
}
|
||||
|
||||
auth.AuthorizeScopes(approvedScopes)
|
||||
auth.AuthorizeClaims(ar.Claims)
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// EndSession implements the identity.Manager interface.
|
||||
func (im *IdentifierIdentityManager) EndSession(ctx context.Context, rw http.ResponseWriter, req *http.Request, esr *payload.EndSessionRequest) error {
|
||||
var err error
|
||||
var esrClaims *konnectoidc.IDTokenClaims
|
||||
var clientDetails *clients.Details
|
||||
|
||||
origin := utils.OriginFromRequestHeaders(req.Header)
|
||||
|
||||
clientID := ""
|
||||
if esr.IDTokenHint != nil {
|
||||
// Extended request, verify IDTokenHint and its claims if available.
|
||||
esrClaims = esr.IDTokenHint.Claims.(*konnectoidc.IDTokenClaims)
|
||||
if len(esrClaims.Audience) == 1 {
|
||||
clientID = esrClaims.Audience[0]
|
||||
}
|
||||
clientDetails, err = im.clients.Lookup(ctx, clientID, "", esr.PostLogoutRedirectURI, origin, true)
|
||||
if err != nil {
|
||||
// This error is not fatal since according to
|
||||
// the spec in https://openid.net/specs/openid-connect-session-1_0.html#RPLogout the
|
||||
// id_token_hint is not enforced to match the audience. Instead of fail
|
||||
// we treat it as untrusted client.
|
||||
im.logger.WithError(err).Debugln("IdentifierIdentityManager: id_token_hint does not match request")
|
||||
esrClaims = nil
|
||||
clientDetails = nil
|
||||
}
|
||||
}
|
||||
|
||||
var user *identifierUser
|
||||
u, _ := im.identifier.GetUserFromLogonCookie(ctx, req, 0, false)
|
||||
if u != nil {
|
||||
user = asIdentifierUser(u)
|
||||
// More checks.
|
||||
if clientDetails != nil && user != nil {
|
||||
sub := user.Subject()
|
||||
err = esr.Verify(sub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if clientDetails != nil && clientDetails.Trusted {
|
||||
// Directly end identifier session when a trusted client requests
|
||||
// and honor redirect wish if any.
|
||||
var uri *url.URL
|
||||
uri, err = im.identifier.EndSession(ctx, u, rw, esr.PostLogoutRedirectURI, esr.State)
|
||||
if err != nil {
|
||||
// Do nothing if err.
|
||||
im.logger.WithError(err).Errorln("IdentifierIdentityManager: failed to end session")
|
||||
return err
|
||||
}
|
||||
if uri != nil {
|
||||
// Redirect to uri if end session returned any.
|
||||
return identity.NewRedirectError("", uri)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Ignore when not signed in, for end session.
|
||||
}
|
||||
|
||||
if clientDetails == nil || !clientDetails.Trusted || esr.PostLogoutRedirectURI == nil || esr.PostLogoutRedirectURI.String() == "" {
|
||||
// Handle directly by redirecting to our logout confirm url for untrusted
|
||||
// clients or when no URL was set.
|
||||
u, _ := url.Parse(im.signedOutURI)
|
||||
query := &url.Values{}
|
||||
|
||||
if clientDetails != nil {
|
||||
query.Add("flow", identifier.FlowOIDC)
|
||||
}
|
||||
if esrClaims != nil {
|
||||
query.Add("client_id", clientID)
|
||||
}
|
||||
|
||||
u.RawQuery = query.Encode()
|
||||
return identity.NewRedirectError(oidc.ErrorCodeOIDCInteractionRequired, u)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApproveScopes implements the Backend interface.
|
||||
func (im *IdentifierIdentityManager) ApproveScopes(ctx context.Context, sub string, audience string, approvedScopes map[string]bool) (string, error) {
|
||||
ref := rndm.GenerateRandomString(32)
|
||||
|
||||
// TODO(longsleep): Store generated ref with provided data.
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// ApprovedScopes implements the Backend interface.
|
||||
func (im *IdentifierIdentityManager) ApprovedScopes(ctx context.Context, sub string, audience string, ref string) (map[string]bool, error) {
|
||||
if ref == "" {
|
||||
return nil, fmt.Errorf("IdentifierIdentityManager: invalid ref")
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Fetch implements the identity.Manager interface.
|
||||
func (im *IdentifierIdentityManager) Fetch(ctx context.Context, userID string, sessionRef *string, scopes map[string]bool, requestedClaimsMaps []*payload.ClaimsRequestMap, requestedScopes map[string]bool) (identity.AuthRecord, bool, error) {
|
||||
u, err := im.identifier.GetUserFromID(ctx, userID, sessionRef, requestedScopes)
|
||||
if err != nil {
|
||||
im.logger.WithError(err).Errorln("IdentifierIdentityManager: fetch failed to get user from userID")
|
||||
return nil, false, fmt.Errorf("IdentifierIdentityManager: identifier error")
|
||||
}
|
||||
|
||||
if u == nil {
|
||||
return nil, false, fmt.Errorf("IdentifierIdentityManager: no user")
|
||||
}
|
||||
|
||||
user := asIdentifierUser(u)
|
||||
authorizedScopes, _ := identity.AuthorizeScopes(im, user, scopes)
|
||||
claims := identity.GetUserClaimsForScopes(user, authorizedScopes, requestedClaimsMaps)
|
||||
|
||||
auth := identity.NewAuthRecord(im, user.Subject(), authorizedScopes, nil, claims)
|
||||
auth.SetUser(user)
|
||||
|
||||
return auth, true, nil
|
||||
}
|
||||
|
||||
// Name implements the identity.Manager interface.
|
||||
func (im *IdentifierIdentityManager) Name() string {
|
||||
return im.identifier.Name()
|
||||
}
|
||||
|
||||
// ScopesSupported implements the identity.Manager interface.
|
||||
func (im *IdentifierIdentityManager) ScopesSupported(scopes map[string]bool) []string {
|
||||
scopesSupported := make([]string, len(im.scopesSupported))
|
||||
copy(scopesSupported, im.scopesSupported)
|
||||
|
||||
for _, scope := range im.identifier.ScopesSupported() {
|
||||
scopesSupported = append(scopesSupported, scope)
|
||||
}
|
||||
|
||||
return scopesSupported
|
||||
}
|
||||
|
||||
// ClaimsSupported implements the identity.Manager interface.
|
||||
func (im *IdentifierIdentityManager) ClaimsSupported(claims []string) []string {
|
||||
return im.claimsSupported
|
||||
}
|
||||
|
||||
// AddRoutes implements the identity.Manager interface.
|
||||
func (im *IdentifierIdentityManager) AddRoutes(ctx context.Context, router *mux.Router) {
|
||||
im.identifier.AddRoutes(ctx, router)
|
||||
}
|
||||
|
||||
// OnSetLogon implements the identity.Manager interface.
|
||||
func (im *IdentifierIdentityManager) OnSetLogon(cb func(ctx context.Context, rw http.ResponseWriter, user identity.User) error) error {
|
||||
return im.identifier.OnSetLogon(cb)
|
||||
}
|
||||
|
||||
// OnUnsetLogon implements the identity.Manager interface.
|
||||
func (im *IdentifierIdentityManager) OnUnsetLogon(cb func(ctx context.Context, rw http.ResponseWriter) error) error {
|
||||
return im.identifier.OnUnsetLogon(cb)
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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 managers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
|
||||
"golang.org/x/crypto/blake2b"
|
||||
|
||||
konnectoidc "github.com/libregraph/lico/oidc"
|
||||
)
|
||||
|
||||
func setupSupportedScopes(scopes []string, extra []string, override []string) []string {
|
||||
if len(override) > 0 {
|
||||
return override
|
||||
}
|
||||
|
||||
return append(scopes, extra...)
|
||||
}
|
||||
|
||||
func getPublicSubject(sub []byte, extra []byte) (string, error) {
|
||||
// Hash the raw subject with our specific salt.
|
||||
hasher, err := blake2b.New512([]byte(konnectoidc.LibreGraphIDTokenSubjectSaltV1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hasher.Write(sub)
|
||||
hasher.Write([]byte(" "))
|
||||
hasher.Write(extra)
|
||||
|
||||
// NOTE(longsleep): URL safe encoding for subject is important since many
|
||||
// third party applications validate this with rather strict patterns. We
|
||||
// also inject an @ to ensure its compatible to some apps which require one.
|
||||
s := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
|
||||
return s[:16] + "@" + s[16:], nil
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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 identity
|
||||
|
||||
import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// User defines a most simple user with an id defined as subject.
|
||||
type User interface {
|
||||
Subject() string
|
||||
}
|
||||
|
||||
// UserWithEmail is a User with Email.
|
||||
type UserWithEmail interface {
|
||||
User
|
||||
Email() string
|
||||
EmailVerified() bool
|
||||
}
|
||||
|
||||
// UserWithProfile is a User with Name.
|
||||
type UserWithProfile interface {
|
||||
User
|
||||
Name() string
|
||||
FamilyName() string
|
||||
GivenName() string
|
||||
}
|
||||
|
||||
// UserWithID is a User with a locally unique numeric id.
|
||||
type UserWithID interface {
|
||||
User
|
||||
ID() int64
|
||||
}
|
||||
|
||||
// UserWithUniqueID is a User with a unique string id.
|
||||
type UserWithUniqueID interface {
|
||||
User
|
||||
UniqueID() string
|
||||
}
|
||||
|
||||
// UserWithUsername is a User with an username different from subject.
|
||||
type UserWithUsername interface {
|
||||
User
|
||||
Username() string
|
||||
}
|
||||
|
||||
// UserWithClaims is a User with jwt claims.
|
||||
type UserWithClaims interface {
|
||||
User
|
||||
Claims() jwt.MapClaims
|
||||
}
|
||||
|
||||
// UserWithScopedClaims is a user with jwt claims bound to provided scopes.
|
||||
type UserWithScopedClaims interface {
|
||||
User
|
||||
ScopedClaims(authorizedScopes map[string]bool) jwt.MapClaims
|
||||
}
|
||||
|
||||
// UserWithSessionRef is a user which supports an underlaying session reference.
|
||||
type UserWithSessionRef interface {
|
||||
User
|
||||
SessionRef() *string
|
||||
}
|
||||
|
||||
// PublicUser is a user with a public Subject and a raw id.
|
||||
type PublicUser interface {
|
||||
Subject() string
|
||||
Raw() string
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* 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 identity
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/libregraph/oidc-go"
|
||||
|
||||
konnectoidc "github.com/libregraph/lico/oidc"
|
||||
"github.com/libregraph/lico/oidc/payload"
|
||||
)
|
||||
|
||||
// AuthorizeScopes uses the provided manager and user to filter the provided
|
||||
// scopes and returns a mapping of only the authorized scopes.
|
||||
func AuthorizeScopes(manager Manager, user User, scopes map[string]bool) (map[string]bool, map[string]bool) {
|
||||
if user == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
authorizedScopes := make(map[string]bool)
|
||||
unauthorizedScopes := make(map[string]bool)
|
||||
supportedScopes := make(map[string]bool)
|
||||
for _, scope := range manager.ScopesSupported(scopes) {
|
||||
supportedScopes[scope] = true
|
||||
}
|
||||
|
||||
for scope, authorizedScope := range scopes {
|
||||
for {
|
||||
if !authorizedScope {
|
||||
// Incoming not authorized.
|
||||
break
|
||||
}
|
||||
|
||||
authorizedScope = isKnownScope(scope)
|
||||
|
||||
if !authorizedScope {
|
||||
if _, ok := supportedScopes[scope]; ok {
|
||||
authorizedScope = true
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if authorizedScope {
|
||||
authorizedScopes[scope] = true
|
||||
} else {
|
||||
unauthorizedScopes[scope] = false
|
||||
}
|
||||
}
|
||||
|
||||
return authorizedScopes, unauthorizedScopes
|
||||
}
|
||||
|
||||
// GetUserClaimsForScopes returns a mapping of user claims of the provided user
|
||||
// filtered by the provided scopes.
|
||||
func GetUserClaimsForScopes(user User, scopes map[string]bool, requestedClaimsMaps []*payload.ClaimsRequestMap) map[string]jwt.Claims {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
claims := make(map[string]jwt.Claims)
|
||||
|
||||
if authorizedScope, _ := scopes[oidc.ScopeEmail]; authorizedScope {
|
||||
if userWithEmail, ok := user.(UserWithEmail); ok {
|
||||
claims[oidc.ScopeEmail] = &konnectoidc.EmailClaims{
|
||||
Email: userWithEmail.Email(),
|
||||
EmailVerified: userWithEmail.EmailVerified(),
|
||||
}
|
||||
}
|
||||
}
|
||||
if authorizedScope, _ := scopes[oidc.ScopeProfile]; authorizedScope {
|
||||
var profileClaims *konnectoidc.ProfileClaims
|
||||
if userWithProfile, ok := user.(UserWithProfile); ok {
|
||||
profileClaims = &konnectoidc.ProfileClaims{
|
||||
Name: userWithProfile.Name(),
|
||||
FamilyName: userWithProfile.FamilyName(),
|
||||
GivenName: userWithProfile.GivenName(),
|
||||
}
|
||||
}
|
||||
if userWithUsername, ok := user.(UserWithUsername); ok {
|
||||
if profileClaims == nil {
|
||||
profileClaims = &konnectoidc.ProfileClaims{
|
||||
PreferredUsername: userWithUsername.Username(),
|
||||
}
|
||||
} else {
|
||||
profileClaims.PreferredUsername = userWithUsername.Username()
|
||||
}
|
||||
}
|
||||
if profileClaims != nil {
|
||||
claims[oidc.ScopeProfile] = profileClaims
|
||||
}
|
||||
}
|
||||
|
||||
// Add additional supported values for email and profile claims.
|
||||
unknownRequestedClaimsWithValue := make(map[string]interface{})
|
||||
for _, requestedClaimMap := range requestedClaimsMaps {
|
||||
for requestedClaim, requestedClaimEntry := range *requestedClaimMap {
|
||||
// NOTE(longsleep): We ignore the actuall value of the claim request
|
||||
// and always return requested scopes with standard behavior.
|
||||
if scope, ok := payload.GetScopeForClaim(requestedClaim); ok {
|
||||
if authorizedScope, _ := scopes[scope]; !authorizedScope {
|
||||
// Add claim values if known.
|
||||
switch scope {
|
||||
case oidc.ScopeEmail:
|
||||
if userWithEmail, ok := user.(UserWithEmail); ok {
|
||||
scopeClaims := konnectoidc.NewEmailClaims(claims[scope])
|
||||
if scopeClaims == nil {
|
||||
scopeClaims = &konnectoidc.EmailClaims{}
|
||||
claims[scope] = scopeClaims
|
||||
}
|
||||
switch requestedClaim {
|
||||
case oidc.EmailClaim:
|
||||
scopeClaims.Email = userWithEmail.Email()
|
||||
fallthrough // Always include EmailVerified claim.
|
||||
case oidc.EmailVerifiedClaim:
|
||||
scopeClaims.EmailVerified = userWithEmail.EmailVerified()
|
||||
}
|
||||
}
|
||||
case oidc.ScopeProfile:
|
||||
if userWithProfile, ok := user.(UserWithProfile); ok {
|
||||
scopeClaims := konnectoidc.NewProfileClaims(claims[scope])
|
||||
if scopeClaims == nil {
|
||||
scopeClaims = &konnectoidc.ProfileClaims{}
|
||||
claims[scope] = scopeClaims
|
||||
}
|
||||
switch requestedClaim {
|
||||
case oidc.NameClaim:
|
||||
scopeClaims.Name = userWithProfile.Name()
|
||||
case oidc.FamilyNameClaim:
|
||||
scopeClaims.Name = userWithProfile.FamilyName()
|
||||
case oidc.GivenNameClaim:
|
||||
scopeClaims.Name = userWithProfile.GivenName()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Add claims which are unknown here to a list of unknown claims
|
||||
// with value if the requested claim is with value. This returns
|
||||
// the requested claim as is with the provided value.
|
||||
if requestedClaimEntry != nil && requestedClaimEntry.Value != nil {
|
||||
unknownRequestedClaimsWithValue[requestedClaim] = requestedClaimEntry.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add extra claims. Those can either come from the backend user if it
|
||||
// has own scoped claims or might be defined as value by the request.
|
||||
var claimsWithoutScope jwt.MapClaims
|
||||
if userWithScopedClaims, ok := user.(UserWithScopedClaims); ok {
|
||||
// Inject additional scope claims.
|
||||
claimsWithoutScope = userWithScopedClaims.ScopedClaims(scopes)
|
||||
}
|
||||
if len(unknownRequestedClaimsWithValue) > 0 {
|
||||
if claimsWithoutScope == nil {
|
||||
claimsWithoutScope = make(jwt.MapClaims)
|
||||
}
|
||||
for claim, value := range unknownRequestedClaimsWithValue {
|
||||
claimsWithoutScope[claim] = value
|
||||
}
|
||||
}
|
||||
if claimsWithoutScope != nil {
|
||||
claims[""] = claimsWithoutScope
|
||||
}
|
||||
|
||||
return claims
|
||||
}
|
||||
|
||||
// GetSessionRef builds a per user and audience unique identifier.
|
||||
func GetSessionRef(label string, audience string, userID string) *string {
|
||||
if userID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NOTE(longsleep): For now we ignore the audience. Seems not to have any
|
||||
// use to keep multiple sessions from Konnect per audience.
|
||||
sessionRef := fmt.Sprintf("%s:-:%s", label, userID)
|
||||
return &sessionRef
|
||||
}
|
||||
|
||||
func isKnownScope(scope string) bool {
|
||||
// Only authorize the scopes we know.
|
||||
switch scope {
|
||||
case oidc.ScopeOpenID:
|
||||
default:
|
||||
// Unknown scopes end up here and are not getting authorized.
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user