Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
BROWSER=none
VITE_KOPANO_BUILD=0.0.0-dev-env
@@ -0,0 +1,38 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
# dependencies
/node_modules
/.yarninstall
# testing
/coverage
.eslintcache
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# i18n
src/locales/*.json
src/locales/**/*.json
# yarn
.pnp.*
!.yarnrc.yml
!.yarn/
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
+7
View File
@@ -0,0 +1,7 @@
compressionLevel: mixed
enableGlobalCache: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.0.2.cjs
+65
View File
@@ -0,0 +1,65 @@
# Tools
YARN ?= yarn
# Variables
VERSION ?= $(shell git describe --tags --always --dirty --match=v* 2>/dev/null | sed 's/^v//' || \
cat $(CURDIR)/../.version 2> /dev/null || echo 0.0.0-unreleased)
# Build
.PHONY: all
all: build
.PHONY: build
build: vendor | src i18n ; $(info building identifier Webapp ...) @
@rm -rf build
VITE_KOPANO_BUILD="${VERSION}" CI=false $(YARN) run build
.PHONY: src
src:
@$(MAKE) -C src
.PHONY: i18n
i18n: vendor
@$(MAKE) -C i18n
.PHONY: lint
lint: vendor ; $(info running eslint ...) @
@$(YARN) lint . --cache && echo "eslint: no lint errors"
.PHONY: lint-checkstyle
lint-checkstyle: vendor ; $(info running eslint checkstyle ...) @
@mkdir -p ../test
$(YARN) lint -f checkstyle -o ../test/tests.eslint.xml . || true
# Yarn
.PHONY: vendor
vendor: .yarninstall
.yarninstall: package.json ; $(info getting depdencies with yarn ...) @
@$(YARN) install --immutable
@touch $@
# Stuff
.PHONY: licenses
licenses:
echo "## LibreGraph Connect identifier web app\n"
@$(YARN) run licenses
.PHONY: clean ; $(info cleaning identifier Webapp ...) @
clean:
$(YARN) cache clean
@rm -rf build
@rm -rf node_modules
@rm -f .yarninstall
@$(MAKE) -C src clean
.PHONY: version
version:
@echo $(VERSION)
+3
View File
@@ -0,0 +1,3 @@
# LibreGraph Connect Identifier
Web app for browser sign-in, sign-out and account management.
+146
View File
@@ -0,0 +1,146 @@
/*
* 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 identifier
import (
"bytes"
"fmt"
"net/http"
"github.com/libregraph/oidc-go"
"github.com/longsleep/rndm"
"github.com/libregraph/lico/identifier/meta"
"github.com/libregraph/lico/identifier/meta/scopes"
)
func (i *Identifier) writeWebappIndexHTML(rw http.ResponseWriter, req *http.Request) {
nonce := rndm.GenerateRandomString(32)
// FIXME(longsleep): Set a secure CSP. Right now we need `data:` for images
// since it is used. Since `data:` URLs possibly could allow xss, a better
// way should be found for our early loading inline SVG stuff.
rw.Header().Set("Content-Security-Policy", fmt.Sprintf("default-src 'self'; img-src 'self' data:; font-src 'self' data:; script-src 'self' 'nonce-%s'; style-src 'self' 'nonce-%s'; base-uri 'none'; frame-ancestors 'none';", nonce, nonce))
// Write index with random nonce to response.
index := bytes.ReplaceAll(i.webappIndexHTML, []byte("__CSP_NONCE__"), []byte(nonce))
rw.Write(index)
}
func (i Identifier) writeHelloResponse(rw http.ResponseWriter, req *http.Request, r *HelloRequest, identifiedUser *IdentifiedUser) (*HelloResponse, error) {
var err error
response := &HelloResponse{
State: r.State,
Branding: &meta.Branding{
BannerLogo: i.defaultBannerLogo,
UsernameHintText: i.Config.DefaultUsernameHintText,
SignInPageText: i.Config.DefaultSignInPageText,
SignInPageLogoURI: i.Config.DefaultSignInPageLogoURI,
Locales: i.Config.UILocales,
},
}
handleHelloLoop:
for {
// Check prompt value.
switch {
case r.Prompts[oidc.PromptNone] == true:
// Never show sign-in, directly return error.
return nil, fmt.Errorf("prompt none requested")
case r.Prompts[oidc.PromptLogin] == true:
// Ignore all potential sources, when prompt login was requested.
if identifiedUser != nil {
response.Username = identifiedUser.Username()
response.DisplayName = identifiedUser.Name()
if response.Username != "" {
response.Success = true
}
}
break handleHelloLoop
default:
// Let all other prompt values pass.
}
if identifiedUser == nil {
// Check if logged in via cookie.
identifiedUser, err = i.GetUserFromLogonCookie(req.Context(), req, r.MaxAge, true)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode logon cookie in hello")
}
}
if identifiedUser != nil {
response.Username = identifiedUser.Username()
response.DisplayName = identifiedUser.Name()
if response.Username != "" {
response.Success = true
break
}
}
break
}
if !response.Success {
return response, nil
}
switch r.Flow {
case FlowOAuth:
fallthrough
case FlowConsent:
fallthrough
case FlowOIDC:
// TODO(longsleep): Add something to validate the parameters.
clientDetails, err := i.clients.Lookup(req.Context(), r.ClientID, "", r.RedirectURI, "", true)
if err != nil {
return nil, err
}
promptConsent := false
// Check prompt value.
switch {
case r.Prompts[oidc.PromptConsent] == true:
promptConsent = true
default:
// Let all other prompt values pass.
}
// If not trusted, always force consent.
if !clientDetails.Trusted {
promptConsent = true
}
if promptConsent {
// TODO(longsleep): Filter scopes to scopes we know about and all.
response.Next = FlowConsent
response.Scopes = r.Scopes
response.ClientDetails = clientDetails
response.Meta = &meta.Meta{
Scopes: scopes.NewScopesFromIDs(r.Scopes, i.meta.Scopes),
}
}
// Add authorize endpoint URI as continue URI.
response.ContinueURI = i.authorizationEndpointURI.String()
response.Flow = r.Flow
}
return response, nil
}
@@ -0,0 +1,54 @@
/*
* 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 backends
import (
"context"
"github.com/libregraph/lico/identifier/meta/scopes"
"github.com/libregraph/lico/identity"
)
// A Backend is an identifier Backend providing functionality to logon and to
// fetch user meta data.
type Backend interface {
RunWithContext(context.Context) error
Logon(ctx context.Context, audience string, username string, password string) (success bool, userID *string, sessionRef *string, user UserFromBackend, err error)
GetUser(ctx context.Context, userID string, sessionRef *string, requestedScopes map[string]bool) (user UserFromBackend, err error)
ResolveUserByUsername(ctx context.Context, username string) (user UserFromBackend, err error)
RefreshSession(ctx context.Context, userID string, sessionRef *string, claims map[string]interface{}) error
DestroySession(ctx context.Context, sessionRef *string) error
UserClaims(userID string, authorizedScopes map[string]bool) map[string]interface{}
ScopesSupported() []string
ScopesMeta() *scopes.Scopes
Name() string
}
// UserFromBackend are users as provided by backends which can have additional
// claims together with a user name.
type UserFromBackend interface {
identity.UserWithUsername
BackendClaims() map[string]interface{}
BackendScopes() []string
RequiredScopes() []string
}
@@ -0,0 +1,41 @@
/*
* 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 ldap
// Define some known LDAP attribute descriptors.
const (
AttributeDN = "dn"
AttributeLogin = "uid"
AttributeEmail = "mail"
AttributeName = "cn"
AttributeFamilyName = "sn"
AttributeGivenName = "givenName"
AttributeUUID = "uuid"
)
// Additional mappable virtual attributes.
const (
AttributeNumericUID = "konnectNumericID"
)
// Define our known LDAP attribute value types.
const (
AttributeValueTypeText = "text"
AttributeValueTypeBinary = "binary"
AttributeValueTypeUUID = "uuid"
)
@@ -0,0 +1,670 @@
/*
* 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 ldap
import (
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-ldap/ldap/v3"
uuid "github.com/gofrs/uuid"
"github.com/libregraph/oidc-go"
"github.com/sirupsen/logrus"
"golang.org/x/time/rate"
konnect "github.com/libregraph/lico"
"github.com/libregraph/lico/config"
"github.com/libregraph/lico/identifier/backends"
"github.com/libregraph/lico/identifier/meta/scopes"
)
const ldapIdentifierBackendName = "identifier-ldap"
var ldapSupportedScopes = []string{
oidc.ScopeProfile,
oidc.ScopeEmail,
konnect.ScopeUniqueUserID,
konnect.ScopeRawSubject,
}
// LDAPIdentifierBackend is a backend for the Identifier which connects LDAP.
type LDAPIdentifierBackend struct {
addr string
isTLS bool
bindDN string
bindPassword string
baseDN string
scope int
searchFilter string
getFilter string
entryIDMapping []string
attributeMapping ldapAttributeMapping
supportedScopes []string
logger logrus.FieldLogger
dialer *net.Dialer
tlsConfig *tls.Config
timeout int
limiter *rate.Limiter
}
type ldapAttributeMapping map[string]string
var ldapDefaultAttributeMapping = ldapAttributeMapping{
AttributeLogin: AttributeLogin,
AttributeEmail: AttributeEmail,
AttributeName: AttributeName,
AttributeFamilyName: AttributeFamilyName,
AttributeGivenName: AttributeGivenName,
AttributeUUID: AttributeUUID,
fmt.Sprintf("%s_type", AttributeUUID): AttributeValueTypeText,
}
func (m ldapAttributeMapping) attributes() []string {
attributes := make([]string, len(m)+1)
attributes[0] = AttributeDN
idx := 1
for _, attribute := range m {
attributes[idx] = attribute
idx++
}
return attributes
}
type ldapUser struct {
entryID string
id int64
data ldapAttributeMapping
}
func newLdapUser(entryID string, mapping ldapAttributeMapping, entry *ldap.Entry) (*ldapUser, error) {
// Go through all returned attributes, add them to the local data set if
// we know them in the mapping.
var id int64
data := make(ldapAttributeMapping)
for _, attribute := range entry.Attributes {
if len(attribute.Values) == 0 {
continue
}
for n, mapped := range mapping {
// LDAP attribute descriptors / short names are case insensitive. See
// https://tools.ietf.org/html/rfc4512#page-4.
if strings.ToLower(attribute.Name) == strings.ToLower(mapped) {
// Check if we need conversion.
switch mapping[fmt.Sprintf("%s_type", n)] {
case AttributeValueTypeBinary:
// Binary gets encoded witih Base64.
data[n] = base64.StdEncoding.EncodeToString(attribute.ByteValues[0])
case AttributeValueTypeUUID:
// Try to decode as UUID https://tools.ietf.org/html/rfc4122 and
// serialize to string.
if value, err := uuid.FromBytes(attribute.ByteValues[0]); err == nil {
data[n] = value.String()
}
default:
data[n] = attribute.Values[0]
}
if n == AttributeNumericUID {
numericID, err := strconv.ParseInt(data[n], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid numeric ID %v in record", err)
}
id = numericID
}
}
}
}
return &ldapUser{
entryID: entryID,
id: id,
data: data,
}, nil
}
func (u *ldapUser) getAttributeValue(n string) string {
if n == "" {
return ""
}
return u.data[n]
}
func (u *ldapUser) Subject() string {
return u.entryID
}
func (u *ldapUser) Email() string {
return u.getAttributeValue(AttributeEmail)
}
func (u *ldapUser) EmailVerified() bool {
return false
}
func (u *ldapUser) Name() string {
return u.getAttributeValue(AttributeName)
}
func (u *ldapUser) FamilyName() string {
return u.getAttributeValue(AttributeFamilyName)
}
func (u *ldapUser) GivenName() string {
return u.getAttributeValue(AttributeGivenName)
}
func (u *ldapUser) Username() string {
return u.getAttributeValue(AttributeLogin)
}
func (u *ldapUser) ID() int64 {
return u.id
}
func (u *ldapUser) UniqueID() string {
return u.getAttributeValue(AttributeUUID)
}
func (u *ldapUser) BackendClaims() map[string]interface{} {
claims := make(map[string]interface{})
claims[konnect.IdentifiedUserIDClaim] = u.entryID
return claims
}
func (u *ldapUser) BackendScopes() []string {
return nil
}
func (u *ldapUser) RequiredScopes() []string {
return nil
}
// NewLDAPIdentifierBackend creates a new LDAPIdentifierBackend with the provided
// parameters.
func NewLDAPIdentifierBackend(
c *config.Config,
tlsConfig *tls.Config,
uriString,
bindDN,
bindPassword,
baseDN,
scopeString,
filter string,
subAttributes []string,
mappedAttributes map[string]string,
) (*LDAPIdentifierBackend, error) {
var err error
var scope int
var uri *url.URL
for {
if uriString == "" {
err = fmt.Errorf("server must not be empty")
break
}
uri, err = url.Parse(uriString)
if err != nil {
break
}
if bindDN == "" && bindPassword != "" {
err = fmt.Errorf("bind DN must not be empty when bind password is given")
break
}
if baseDN == "" {
err = fmt.Errorf("base DN must not be empty")
break
}
switch scopeString {
case "sub":
scope = ldap.ScopeWholeSubtree
case "one":
scope = ldap.ScopeSingleLevel
case "base":
scope = ldap.ScopeBaseObject
case "":
scope = ldap.ScopeWholeSubtree
default:
err = fmt.Errorf("unknown scope value: %v, must be one of sub, one or base", scopeString)
}
if err != nil {
break
}
break
}
if err != nil {
return nil, fmt.Errorf("ldap identifier backend %v", err)
}
attributeMapping := ldapAttributeMapping{}
for k, v := range ldapDefaultAttributeMapping {
if mapped, ok := mappedAttributes[k]; ok && mapped != "" {
v = mapped
}
attributeMapping[k] = v
c.Logger.WithField("attribute", fmt.Sprintf("%v:%v", k, v)).Debugln("ldap identifier backend set attribute")
}
// Build supported scopes based on default scopes and scope mapping.
supportedScopes := make([]string, len(ldapSupportedScopes))
copy(supportedScopes, ldapSupportedScopes)
if numericUIDAttribute := mappedAttributes[AttributeNumericUID]; numericUIDAttribute != "" {
supportedScopes = append(supportedScopes, konnect.ScopeNumericID)
attributeMapping[AttributeNumericUID] = numericUIDAttribute
c.Logger.WithField("attribute", fmt.Sprintf("%v:%v", AttributeNumericUID, numericUIDAttribute)).Debugln("ldap identifier backend use attribute")
}
if filter == "" {
filter = "(objectClass=inetOrgPerson)"
}
c.Logger.WithField("filter", filter).Debugln("ldap identifier backend set filter")
loginAttribute := attributeMapping[AttributeLogin]
addr := uri.Host
isTLS := false
switch uri.Scheme {
case "":
uri.Scheme = "ldap"
fallthrough
case "ldap":
if uri.Port() == "" {
addr += ":389"
}
case "ldaps":
if uri.Port() == "" {
addr += ":636"
}
// To be able to verify the servers TLS certificate we need to set the
// server's hostname. (Normally tls.DialWithDialer() would take care of
// that, but we're not using that in LDAPIdentifierBackend.connect())
if !tlsConfig.InsecureSkipVerify && tlsConfig.ServerName == "" {
tlsConfig.ServerName = uri.Hostname()
}
isTLS = true
default:
err = fmt.Errorf("invalid URI scheme: %v", uri.Scheme)
}
if err != nil {
return nil, fmt.Errorf("ldap identifier backend %v", err)
}
var entryIDMapping []string
if len(subAttributes) > 0 {
entryIDMapping = subAttributes
c.Logger.WithField("mapping", entryIDMapping).Debugln("ldap identifier sub is mapped")
}
b := &LDAPIdentifierBackend{
addr: addr,
isTLS: isTLS,
bindDN: bindDN,
bindPassword: bindPassword,
baseDN: baseDN,
scope: scope,
searchFilter: fmt.Sprintf("(&(%s)(%s=%%s))", filter, loginAttribute),
getFilter: filter,
entryIDMapping: entryIDMapping,
attributeMapping: attributeMapping,
supportedScopes: supportedScopes,
logger: c.Logger,
dialer: &net.Dialer{
Timeout: ldap.DefaultTimeout,
DualStack: true,
},
tlsConfig: tlsConfig,
timeout: 60, //XXX(longsleep): make timeout configuration.
limiter: rate.NewLimiter(100, 200), //XXX(longsleep): make rate limits configuration.
}
b.logger.WithField("ldap", fmt.Sprintf("%s://%s ", uri.Scheme, addr)).Infoln("ldap server identifier backend set up")
return b, nil
}
// RunWithContext implements the Backend interface.
func (b *LDAPIdentifierBackend) RunWithContext(ctx context.Context) error {
return nil
}
// Logon implements the Backend interface, enabling Logon with user name and
// password as provided. Requests are bound to the provided context.
func (b *LDAPIdentifierBackend) Logon(ctx context.Context, audience, username, password string) (bool, *string, *string, backends.UserFromBackend, error) {
loginAttributeName := b.attributeMapping[AttributeLogin]
if loginAttributeName == "" {
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon impossible as no login attribute is set")
}
l, err := b.connect(ctx)
if err != nil {
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon connect error: %v", err)
}
defer l.Close()
// Search for the given username.
entry, err := b.searchUsername(l, username, b.attributeMapping.attributes())
switch {
case ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject):
return false, nil, nil, nil, nil
}
if err != nil {
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon search error: %v", err)
}
if !strings.EqualFold(entry.GetEqualFoldAttributeValue(loginAttributeName), username) {
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon search returned wrong user")
}
// Bind as the user to verify the password.
err = l.Bind(entry.DN, password)
switch {
case ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials):
return false, nil, nil, nil, nil
}
if err != nil {
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon error: %v", err)
}
entryID := b.entryIDFromEntry(b.attributeMapping, entry)
if entryID == "" {
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon entry without entry ID: %v", entry.DN)
}
user, err := newLdapUser(entryID, b.attributeMapping, entry)
if err != nil {
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon entry data error: %v", err)
}
// Use the users subject as user id.
userID := user.Subject()
b.logger.WithFields(logrus.Fields{
"username": user.Username(),
"id": userID,
}).Debugln("ldap identifier backend logon")
return true, &userID, nil, user, nil
}
// ResolveUserByUsername implements the Beckend interface, providing lookup for
// user by providing the username. Requests are bound to the provided context.
func (b *LDAPIdentifierBackend) ResolveUserByUsername(ctx context.Context, username string) (backends.UserFromBackend, error) {
loginAttributeName := b.attributeMapping[AttributeLogin]
if loginAttributeName == "" {
return nil, fmt.Errorf("ldap identifier backend resolve impossible as no login attribute is set")
}
l, err := b.connect(ctx)
if err != nil {
return nil, fmt.Errorf("ldap identifier backend resolve connect error: %v", err)
}
defer l.Close()
// Search for the given username.
entry, err := b.searchUsername(l, username, b.attributeMapping.attributes())
switch {
case ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject):
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("ldap identifier backend resolve search error: %v", err)
}
if !strings.EqualFold(entry.GetEqualFoldAttributeValue(loginAttributeName), username) {
return nil, fmt.Errorf("ldap identifier backend resolve search returned wrong user")
}
newEntryID := b.entryIDFromEntry(b.attributeMapping, entry)
user, err := newLdapUser(newEntryID, b.attributeMapping, entry)
if err != nil {
return nil, fmt.Errorf("ldap identifier backend resolve entry data error: %v", err)
}
return user, nil
}
// GetUser implements the Backend interface, providing user meta data retrieval
// for the user specified by the userID. Requests are bound to the provided
// context.
func (b *LDAPIdentifierBackend) GetUser(ctx context.Context, entryID string, sessionRef *string, requestedScopes map[string]bool) (backends.UserFromBackend, error) {
l, err := b.connect(ctx)
if err != nil {
return nil, fmt.Errorf("ldap identifier backend get user connect error: %v", err)
}
defer l.Close()
entry, err := b.getUser(l, entryID, b.attributeMapping.attributes())
if err != nil {
return nil, fmt.Errorf("ldap identifier backend get user error: %v", err)
}
newEntryID := b.entryIDFromEntry(b.attributeMapping, entry)
if !strings.EqualFold(newEntryID, entryID) {
return nil, fmt.Errorf("ldap identifier backend get user returned wrong user")
}
user, err := newLdapUser(newEntryID, b.attributeMapping, entry)
if err != nil {
return nil, fmt.Errorf("ldap identifier backend get user entry data error: %v", err)
}
return user, err
}
// RefreshSession implements the Backend interface.
func (b *LDAPIdentifierBackend) RefreshSession(ctx context.Context, userID string, sessionRef *string, claims map[string]interface{}) error {
return nil
}
// DestroySession implements the Backend interface providing destroy to KC session.
func (b *LDAPIdentifierBackend) DestroySession(ctx context.Context, sessionRef *string) error {
return nil
}
// UserClaims implements the Backend interface, providing user specific claims
// for the user specified by the userID.
func (b *LDAPIdentifierBackend) UserClaims(userID string, authorizedScopes map[string]bool) map[string]interface{} {
return nil
}
// ScopesSupported implements the Backend interface, providing supported scopes
// when running this backend.
func (b *LDAPIdentifierBackend) ScopesSupported() []string {
return b.supportedScopes
}
// ScopesMeta implements the Backend interface, providing meta data for
// supported scopes.
func (b *LDAPIdentifierBackend) ScopesMeta() *scopes.Scopes {
return nil
}
// Name implements the Backend interface.
func (b *LDAPIdentifierBackend) Name() string {
return ldapIdentifierBackendName
}
func (b *LDAPIdentifierBackend) connect(parentCtx context.Context) (*ldap.Conn, error) {
// A timeout for waiting for a limiter slot. The timeout also includes the
// time to connect to the LDAP server which as a consequence means that both
// getting a free slot and establishing the connection are one timeout.
ctx, cancel := context.WithTimeout(parentCtx, time.Duration(b.timeout)*time.Second)
defer cancel()
err := b.limiter.Wait(ctx)
if err != nil {
return nil, err
}
c, err := b.dialer.DialContext(ctx, "tcp", b.addr)
if err != nil {
return nil, ldap.NewError(ldap.ErrorNetwork, err)
}
var l *ldap.Conn
if b.isTLS {
sc := tls.Client(c, b.tlsConfig)
err = sc.Handshake()
if err != nil {
c.Close()
return nil, ldap.NewError(ldap.ErrorNetwork, err)
}
l = ldap.NewConn(sc, true)
} else {
l = ldap.NewConn(c, false)
}
l.Start()
// Bind with general user (which is preferably read only).
if b.bindDN != "" {
err = l.Bind(b.bindDN, b.bindPassword)
if err != nil {
return nil, err
}
}
return l, nil
}
func (b *LDAPIdentifierBackend) searchUsername(l *ldap.Conn, username string, attributes []string) (*ldap.Entry, error) {
base, filter := b.baseAndSearchFilterFromUsername(username)
// Search for the given username.
searchRequest := ldap.NewSearchRequest(
base,
b.scope, ldap.NeverDerefAliases, 1, b.timeout, false,
filter,
attributes,
nil,
)
sr, err := l.Search(searchRequest)
if err != nil {
return nil, err
}
switch len(sr.Entries) {
case 0:
// Nothing found.
return nil, ldap.NewError(ldap.LDAPResultNoSuchObject, err)
case 1:
// Exactly one found, success.
return sr.Entries[0], nil
default:
// Invalid when multiple matched.
return nil, fmt.Errorf("user too many entries returned")
}
}
func (b *LDAPIdentifierBackend) getUser(l *ldap.Conn, entryID string, attributes []string) (*ldap.Entry, error) {
base, filter := b.baseAndGetFilterFromEntryID(entryID)
if base == "" || filter == "" || entryID == "" {
return nil, fmt.Errorf("ldap identifier backend get user invalid user ID: %v", entryID)
}
scope := b.scope
if base == entryID {
// Ensure that scope is limited, when directly requesting an entry.
scope = ldap.ScopeBaseObject
}
// search for the given DN.
searchRequest := ldap.NewSearchRequest(
base,
scope, ldap.NeverDerefAliases, 1, b.timeout, false,
filter,
attributes,
nil,
)
sr, err := l.Search(searchRequest)
if err != nil {
return nil, err
}
if len(sr.Entries) != 1 {
return nil, fmt.Errorf("user does not exist or too many entries returned")
}
return sr.Entries[0], nil
}
func (b *LDAPIdentifierBackend) entryIDFromEntry(mapping ldapAttributeMapping, entry *ldap.Entry) string {
if b.entryIDMapping != nil {
// Encode as URL query.
values := url.Values{}
for _, k := range b.entryIDMapping {
v := entry.GetEqualFoldAttributeValues(k)
if len(v) > 0 {
values[k] = v
}
}
// URL encode values to string.
return values.Encode()
}
// Use DN by default is no mapping is set.
return entry.DN
}
func (b *LDAPIdentifierBackend) baseAndGetFilterFromEntryID(entryID string) (string, string) {
if b.entryIDMapping != nil {
// Parse entryID as URL encoded query values, and build & filter to search for them all.
if values, err := url.ParseQuery(entryID); err == nil {
filter := ""
for k, values := range values {
for _, value := range values {
filter = fmt.Sprintf("%s(%s=%s)", filter, k, ldap.EscapeFilter(value))
}
}
if filter != "" {
return b.baseDN, fmt.Sprintf("(&%s%s)", b.getFilter, filter)
}
}
// Failed to parse entry ID.
return "", ""
}
// Map DN to entryID.
_, err := ldap.ParseDN(entryID)
if err != nil {
return "", ""
}
return entryID, b.getFilter
}
func (b *LDAPIdentifierBackend) baseAndSearchFilterFromUsername(username string) (string, string) {
// Build search filter with username.
return b.baseDN, fmt.Sprintf(b.searchFilter, ldap.EscapeFilter(username))
}
@@ -0,0 +1,660 @@
/*
* Copyright 2021 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 libregraph
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/cevaris/ordered_map"
"github.com/libregraph/oidc-go"
"github.com/sirupsen/logrus"
konnect "github.com/libregraph/lico"
"github.com/libregraph/lico/config"
"github.com/libregraph/lico/identifier"
"github.com/libregraph/lico/identifier/backends"
"github.com/libregraph/lico/identifier/meta/scopes"
identityClients "github.com/libregraph/lico/identity/clients"
"github.com/libregraph/lico/utils"
)
const libreGraphIdentifierBackendName = "identifier-libregraph"
const (
OpenTypeExtensionType = "#microsoft.graph.openTypeExtension"
IdentityClaimsExtensionName = "libregraph.identityClaims"
IDTokenClaimsExtensionName = "libregraph.idTokenClaims"
AccessTokenClaimsExtensionName = "libregraph.accessTokenClaims"
RequestedScopesExtensionName = "libregraph.requestedScopes"
SessionExtensionName = "libregraph.session"
)
const (
apiPathMe = "/api/v1/me"
apiPathUsers = "/api/v1/users"
apiPathRevokeSignInSession = "/api/v1/me/revokeSignInSession"
)
var libreGraphSpportedScopes = []string{
oidc.ScopeProfile,
oidc.ScopeEmail,
konnect.ScopeUniqueUserID,
konnect.ScopeRawSubject,
}
type LibreGraphIdentifierBackend struct {
config *config.Config
supportedScopes []string
logger logrus.FieldLogger
tlsConfig *tls.Config
client *http.Client
baseURLMap *ordered_map.OrderedMap
useMultipleBackends bool
clients *identityClients.Registry
}
type libreGraphUser struct {
AccountEnabled bool `json:"accountEnabled"`
DisplayName string `json:"displayName"`
RawGivenName string `json:"givenName"`
ID string `json:"id"`
Mail string `json:"mail"`
Surname string `json:"surname"`
UserPrincipalName string `json:"userPrincipalName"`
Extensions []map[string]interface{} `json:"extensions"`
identityClaims map[string]interface{}
requestedScopes []string
requiredScopes []string
}
func decodeLibreGraphUser(r io.Reader) (*libreGraphUser, error) {
decoder := json.NewDecoder(r)
u := &libreGraphUser{}
if err := decoder.Decode(u); err != nil {
return nil, err
}
identityClaims := make(map[string]interface{})
identityClaims[konnect.IdentifiedUserIDClaim] = u.ID
var idTokenClaims map[string]interface{}
var accessTokenClaims map[string]interface{}
var requestedScopes []string
for _, extension := range u.Extensions {
if odataType, ok := extension["@odata.type"]; ok && odataType.(string) != OpenTypeExtensionType {
continue
}
if extensionName, ok := extension["extensionName"].(string); ok {
switch extensionName {
case IdentityClaimsExtensionName:
if v, ok := extension["claims"].(map[string]interface{}); ok {
for k, v := range v {
if k == konnect.InternalExtraIDTokenClaimsClaim || k == konnect.InternalExtraAccessTokenClaimsClaim {
// Ignore keys which areused internally by IDTokenClaimsExtensionName
// and AccessTokenClaimsExtensionName.
continue
}
identityClaims[k] = v
}
}
case IDTokenClaimsExtensionName:
if idTokenClaims == nil {
idTokenClaims = make(map[string]interface{})
}
if v, ok := extension["claims"].(map[string]interface{}); ok {
for k, v := range v {
idTokenClaims[k] = v
}
}
case AccessTokenClaimsExtensionName:
if accessTokenClaims == nil {
accessTokenClaims = make(map[string]interface{})
}
if v, ok := extension["claims"].(map[string]interface{}); ok {
for k, v := range v {
accessTokenClaims[k] = v
}
}
case RequestedScopesExtensionName:
if values, ok := extension["scopes"].([]interface{}); ok {
for _, v := range values {
if s, ok := v.(string); ok {
requestedScopes = append(requestedScopes, s)
}
}
}
case SessionExtensionName:
if sid, ok := extension[oidc.SessionIDClaim].(string); ok {
if sid != "" {
if accessTokenClaims == nil {
accessTokenClaims = make(map[string]interface{})
}
accessTokenClaims[oidc.SessionIDClaim] = sid
}
}
}
}
}
if idTokenClaims != nil {
// Inject claims as nested identity claim. The key is picket up by the
// token signer and used to extend ID token root claims.
identityClaims[konnect.InternalExtraIDTokenClaimsClaim] = idTokenClaims
}
if accessTokenClaims != nil {
// Inject claims as nested identity claims. The key is picked up by the
// token signer and userinfo handler to extend ID and access token root
// claims based on the request.
identityClaims[konnect.InternalExtraAccessTokenClaimsClaim] = accessTokenClaims
}
if requestedScopes != nil {
u.requestedScopes = requestedScopes
}
u.identityClaims = identityClaims
return u, nil
}
func (u *libreGraphUser) Subject() string {
return u.ID
}
func (u *libreGraphUser) Email() string {
return u.Mail
}
func (u *libreGraphUser) EmailVerified() bool {
return true
}
func (u *libreGraphUser) Name() string {
return u.DisplayName
}
func (u *libreGraphUser) FamilyName() string {
return u.Surname
}
func (u *libreGraphUser) GivenName() string {
return u.RawGivenName
}
func (u *libreGraphUser) Username() string {
return u.UserPrincipalName
}
func (u *libreGraphUser) UniqueID() string {
// Provide our ID as unique ID.
return u.ID
}
func (u *libreGraphUser) BackendClaims() map[string]interface{} {
return u.identityClaims
}
func (u *libreGraphUser) BackendScopes() []string {
return u.requestedScopes
}
func (u *libreGraphUser) RequiredScopes() []string {
return u.requiredScopes
}
func (u *libreGraphUser) setRequiredScopes(selectedScope string, scopeMap *ordered_map.OrderedMap) []string {
var requiredScopes []string
if selectedScope != "" {
requiredScopes = []string{selectedScope}
}
iter := scopeMap.IterFunc()
for kv, ok := iter(); ok; kv, ok = iter() {
if scope := kv.Key.(string); scope != selectedScope {
requiredScopes = append(requiredScopes, "!"+scope)
}
}
u.requiredScopes = requiredScopes
return requiredScopes
}
func (u *libreGraphUser) sessionID() string {
if accessTokenClaims, ok := u.identityClaims[konnect.InternalExtraAccessTokenClaimsClaim].(map[string]interface{}); ok {
if sessionID, withSessionID := accessTokenClaims[oidc.SessionIDClaim].(string); withSessionID {
if sessionID != "" {
return sessionID
}
}
}
return ""
}
func withSelectQuery(r *http.Request) {
if r.Form == nil {
r.Form = make(url.Values)
}
r.Form.Set("$select", "accountEnabled,displayName,givenName,id,mail,surname,userPrincipalName,extensions")
}
func NewLibreGraphIdentifierBackend(
c *config.Config,
tlsConfig *tls.Config,
baseURI string,
baseURIByScope *ordered_map.OrderedMap,
clients *identityClients.Registry,
) (*LibreGraphIdentifierBackend, error) {
if baseURI == "" {
return nil, fmt.Errorf("base uri must not be empty")
}
// Build supported scopes based on default scopes.
supportedScopes := make([]string, len(libreGraphSpportedScopes))
copy(supportedScopes, libreGraphSpportedScopes)
baseURLMap := ordered_map.NewOrderedMapWithArgs([]*ordered_map.KVPair{{
Key: "",
Value: baseURI,
}})
if baseURIByScope != nil {
iter := baseURIByScope.IterFunc()
for kv, ok := iter(); ok; kv, ok = iter() {
if kv.Key == "" {
return nil, fmt.Errorf("scoped base uri with empty scope is not allowed")
}
baseURLMap.Set(kv.Key, kv.Value)
}
}
transport := utils.HTTPTransportWithTLSClientConfig(tlsConfig)
transport.MaxIdleConns = 100
transport.IdleConnTimeout = 30 * time.Second
b := &LibreGraphIdentifierBackend{
config: c,
supportedScopes: supportedScopes,
logger: c.Logger,
tlsConfig: tlsConfig,
client: &http.Client{
Transport: transport,
Timeout: 60 * time.Second,
},
baseURLMap: baseURLMap,
useMultipleBackends: baseURLMap.Len() > 1,
clients: clients,
}
b.logger.WithField("map", baseURLMap).Infoln("libregraph server identified backend connection set up")
return b, nil
}
// RunWithContext implements the Backend interface.
func (b *LibreGraphIdentifierBackend) RunWithContext(ctx context.Context) error {
return nil
}
// Logon implements the Backend interface, enabling Logon with user name and
// password as provided. Requests are bound to the provided context.
func (b *LibreGraphIdentifierBackend) Logon(ctx context.Context, audience, username, password string) (bool, *string, *string, backends.UserFromBackend, error) {
record, _ := identifier.FromRecordContext(ctx)
var requestedScopes map[string]bool
if record != nil {
requestedScopes = record.HelloRequest.Scopes
}
// Inject implicit scopes set by client registration. This is needed here,
// as the requested scopes might not have the implicit scopes applied yet,
// based on the calling stack chain and since we use the scopes to select
// the backend.
registration, _ := b.clients.Get(ctx, audience)
if registration != nil {
_ = registration.ApplyImplicitScopes(requestedScopes)
}
selectedScope, meURL := b.getMeURL(requestedScopes)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil)
if err != nil {
return false, nil, nil, nil, fmt.Errorf("libregraph identifier backend logon request error: %w", err)
}
req.SetBasicAuth(username, password)
if record == nil {
record, _ = identifier.RecordFromRequestContext(ctx, b.config)
}
if record != nil {
// Inject HTTP headers.
if record.HelloRequest != nil {
if record.HelloRequest.Flow != "" {
req.Header.Set("X-Flow", record.HelloRequest.Flow)
}
if record.HelloRequest.RawScope != "" {
req.Header.Set("X-Scope", record.HelloRequest.RawScope)
}
if record.HelloRequest.RawPrompt != "" {
req.Header.Set("X-Prompt", record.HelloRequest.RawPrompt)
}
}
if record.RealIP != "" {
req.Header.Set("X-User-Real-IP", record.RealIP)
}
if record.UserAgent != "" {
req.Header.Set("X-User-Real-User-Agent", record.UserAgent)
}
}
req.Header.Set("User-Agent", utils.DefaultHTTPUserAgent)
// Inject select parameter.
withSelectQuery(req)
response, err := b.client.Do(req)
if err != nil {
return false, nil, nil, nil, fmt.Errorf("libregraph identifier backend logon request failed: %w", err)
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
// breaks
case http.StatusNotFound:
return false, nil, nil, nil, nil
case http.StatusUnauthorized:
return false, nil, nil, nil, nil
default:
return false, nil, nil, nil, fmt.Errorf("libregraph identifier backend logon request unexpected response status: %d", response.StatusCode)
}
user, err := decodeLibreGraphUser(response.Body)
if err != nil {
return false, nil, nil, nil, fmt.Errorf("libregraph identifier backend logon json decode error: %w", err)
}
if !user.AccountEnabled {
return false, nil, nil, nil, nil
}
requiredScopes := user.setRequiredScopes(selectedScope, b.baseURLMap)
// Use the users subject as user id.
userID := user.Subject()
sessionID := user.sessionID()
b.logger.WithFields(logrus.Fields{
"username": user.Username(),
"id": userID,
"scope": requiredScopes,
"sessionID": sessionID,
}).Debugln("libregraph identifier backend logon")
// Put the user into the record (if any).
if record != nil {
record.BackendUser = user
}
return true, &userID, &sessionID, user, nil
}
// GetUser implements the Backend interface, providing user meta data retrieval
// for the user specified by the userID. Requests are bound to the provided
// context.
func (b *LibreGraphIdentifierBackend) GetUser(ctx context.Context, entryID string, sessionRef *string, requestedScopes map[string]bool) (backends.UserFromBackend, error) {
record, _ := identifier.FromRecordContext(ctx)
if record != nil {
if record.BackendUser != nil {
if user, ok := record.BackendUser.(*libreGraphUser); ok {
// Fastpath, if logon previously injected the user.
if user.ID == entryID {
return user, nil
}
}
}
if requestedScopes == nil && record.HelloRequest != nil {
requestedScopes = record.HelloRequest.Scopes
}
}
selectedScope, userURL := b.getUserURL(requestedScopes)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, userURL+"/"+entryID, nil)
if err != nil {
return nil, fmt.Errorf("libregraph identifier backend get user request error: %w", err)
}
// Inject HTTP headers.
if requestedScopes != nil {
rawRequestedScopes := make([]string, 0)
for scope, enabled := range requestedScopes {
if enabled {
rawRequestedScopes = append(rawRequestedScopes, scope)
}
}
req.Header.Set("X-Scope", strings.Join(rawRequestedScopes, " "))
}
if sessionRef != nil {
sessionID := *sessionRef
if !strings.HasPrefix(sessionID, libreGraphIdentifierBackendName+":") {
// Only send the session ID if it is not a ref generated by lico.
req.Header.Set("X-SessionID", sessionID)
}
}
if record == nil {
record, _ = identifier.RecordFromRequestContext(ctx, b.config)
}
if record != nil {
if record.IdentifiedUser != nil {
if ok, ts := record.IdentifiedUser.LoggedOn(); ok {
req.Header.Set("X-User-Logon-At", ts.UTC().Format(http.TimeFormat))
}
}
if record.RealIP != "" {
req.Header.Set("X-User-Real-IP", record.RealIP)
}
if record.UserAgent != "" {
req.Header.Set("X-User-Real-User-Agent", record.UserAgent)
}
}
req.Header.Set("User-Agent", utils.DefaultHTTPUserAgent)
// Inject select parameter.
withSelectQuery(req)
response, err := b.client.Do(req)
if err != nil {
return nil, fmt.Errorf("libregraph identifier backend get user request failed: %w", err)
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
// breaks
case http.StatusNotFound:
return nil, nil
default:
return nil, fmt.Errorf("libregraph identifier backend get user request unexpected response status: %d", response.StatusCode)
}
user, err := decodeLibreGraphUser(response.Body)
if err != nil {
return nil, fmt.Errorf("libregraph identifier backend logon json decode error: %w", err)
}
if !user.AccountEnabled {
return nil, nil
}
user.setRequiredScopes(selectedScope, b.baseURLMap)
return user, nil
}
// ResolveUserByUsername implements the Backend interface, providing lookup for
// user by providing the username. Requests are bound to the provided context.
func (b *LibreGraphIdentifierBackend) ResolveUserByUsername(ctx context.Context, username string) (backends.UserFromBackend, error) {
// Libregraph backend accept both user name and ID lookups, so this is
// the same as GetUser without a session.
return b.GetUser(ctx, username, nil, nil)
}
// RefreshSession implements the Backend interface.
func (b *LibreGraphIdentifierBackend) RefreshSession(ctx context.Context, userID string, sessionRef *string, claims map[string]interface{}) error {
user, err := b.GetUser(ctx, userID, sessionRef, nil)
if err != nil {
return err
}
if user == nil {
return fmt.Errorf("refresh session did not yield a user")
}
return nil
}
// DestroySession implements the Backend interface providing explicit revoke
// of the backend session.
func (b *LibreGraphIdentifierBackend) DestroySession(ctx context.Context, sessionRef *string) error {
var requestedScopes map[string]bool
record, _ := identifier.FromRecordContext(ctx)
if record != nil {
if record.HelloRequest != nil {
requestedScopes = record.HelloRequest.Scopes
}
}
_, revokeSessionURL := b.getRevokeSigninSessionURL(requestedScopes)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, revokeSessionURL, http.NoBody)
if err != nil {
return fmt.Errorf("libregraph identifier backend destroy session request error: %w", err)
}
if sessionRef != nil {
sessionID := *sessionRef
if !strings.HasPrefix(sessionID, libreGraphIdentifierBackendName+":") {
// Only send the session ID if it is not a ref generated by lico.
req.Header.Set("X-SessionID", sessionID)
}
}
if record == nil {
record, _ = identifier.RecordFromRequestContext(ctx, b.config)
}
if record != nil {
// Inject HTTP headers.
if record.RealIP != "" {
req.Header.Set("X-User-Real-IP", record.RealIP)
}
if record.UserAgent != "" {
req.Header.Set("X-User-Real-User-Agent", record.UserAgent)
}
}
req.Header.Set("User-Agent", utils.DefaultHTTPUserAgent)
response, err := b.client.Do(req)
if err != nil {
return fmt.Errorf("libregraph identifier backend destroy session request failed: %w", err)
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
// breaks
case http.StatusNotFound:
return nil
case http.StatusUnauthorized:
return nil
default:
return fmt.Errorf("libregraph identifier backend logon request unexpected response status: %d", response.StatusCode)
}
return nil
}
// UserClaims implements the Backend interface, providing user specific claims
// for the user specified by the userID.
func (b *LibreGraphIdentifierBackend) UserClaims(userID string, authorizedScopes map[string]bool) map[string]interface{} {
return nil
}
// ScopesSupported implements the Backend interface, providing supported scopes
// when running this backend.
func (b *LibreGraphIdentifierBackend) ScopesSupported() []string {
return b.supportedScopes
}
// ScopesMeta implements the Backend interface, providing meta data for
// supported scopes.
func (b *LibreGraphIdentifierBackend) ScopesMeta() *scopes.Scopes {
return nil
}
// Name implements the Backend interface.
func (b *LibreGraphIdentifierBackend) Name() string {
return libreGraphIdentifierBackendName
}
func (b *LibreGraphIdentifierBackend) getBaseURL(requestedScopes map[string]bool) (string, string) {
if b.useMultipleBackends && requestedScopes != nil {
// Loop through configured backends for each requested scope.
for s, v := range requestedScopes {
if !v {
continue
}
if u, ok := b.baseURLMap.Get(s); ok {
return s, u.(string)
}
}
}
// If nothing found, return default.
u, _ := b.baseURLMap.Get("")
return "", u.(string)
}
func (b *LibreGraphIdentifierBackend) getMeURL(requestedScopes map[string]bool) (string, string) {
scope, baseURL := b.getBaseURL(requestedScopes)
return scope, baseURL + apiPathMe
}
func (b *LibreGraphIdentifierBackend) getUserURL(requestedScopes map[string]bool) (string, string) {
scope, baseURL := b.getBaseURL(requestedScopes)
return scope, baseURL + apiPathUsers
}
func (b *LibreGraphIdentifierBackend) getRevokeSigninSessionURL(requestedScopes map[string]bool) (string, string) {
scope, baseURL := b.getBaseURL(requestedScopes)
return scope, baseURL + apiPathRevokeSignInSession
}
+31
View File
@@ -0,0 +1,31 @@
/*
* 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 identifier
// Additional claims as used by the identifier in its own tokens.
const (
SessionIDClaim = "sid"
LogonRefClaim = "lref"
ExternalAuthorityIDClaim = "eaid"
LockedScopesClaim = "lscp"
)
// History claims previously used by the identifier in its own tokens.
const (
ObsoleteUserClaimsClaim = "claims"
)
+56
View File
@@ -0,0 +1,56 @@
/*
* 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 identifier
import (
"net/http"
"net/url"
"github.com/libregraph/lico/config"
"github.com/libregraph/lico/identifier/backends"
)
// Config defines a Server's configuration settings.
type Config struct {
Config *config.Config
BaseURI *url.URL
ScopesConf string
LogonCookieName string
LogonCookieSameSite http.SameSite
ConsentCookieSameSite http.SameSite
StateCookieSameSite http.SameSite
PathPrefix string
StaticFolder string
WebAppDisabled bool
AuthorizationEndpointURI *url.URL
SignedOutEndpointURI *url.URL
DefaultBannerLogo []byte
DefaultSignInPageText *string
DefaultSignInPageLogoURI *string
DefaultUsernameHintText *string
UILocales []string
Backend backends.Backend
}
+89
View File
@@ -0,0 +1,89 @@
/*
* Copyright 2021 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 identifier
import (
"context"
"net"
"net/http"
konnect "github.com/libregraph/lico"
"github.com/libregraph/lico/config"
"github.com/libregraph/lico/identifier/backends"
"github.com/libregraph/lico/utils"
)
// Record is the struct which the identifier puts into the context.
type Record struct {
HelloRequest *HelloRequest
RealIP string
UserAgent string
BackendUser backends.UserFromBackend
IdentifiedUser *IdentifiedUser
}
func NewRecord(req *http.Request, c *config.Config) *Record {
record := &Record{
UserAgent: req.UserAgent(),
}
trusted, _ := utils.IsRequestFromTrustedSource(req, c.TrustedProxyIPs, c.TrustedProxyNets)
if trusted {
record.RealIP = req.Header.Get("X-Real-Ip")
}
if record.RealIP == "" {
if ip, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
record.RealIP = ip
}
}
return record
}
// key is an unexported type for keys defined in this package.
// This prevents collisions with keys defined in other packages.
type key int
// Keys for context data.
// Unexported; Clients use identifier.New{?}Context and
// identifier.From{?}Context functions instead of using these keys directly.
const (
recordKey key = iota
)
// NewRecordContext returns a new Context that carries the Record.
func NewRecordContext(ctx context.Context, record *Record) context.Context {
return context.WithValue(ctx, recordKey, record)
}
// FromRecordContext returns the Record value stored in ctx, if any.
func FromRecordContext(ctx context.Context) (*Record, bool) {
record, ok := ctx.Value(recordKey).(*Record)
return record, ok
}
// RecordFromRequestContext returns a new Record value based on the request
// stored in ctx, if any.
func RecordFromRequestContext(ctx context.Context, c *config.Config) (*Record, bool) {
if req, ok := konnect.FromRequestContext(ctx); ok {
return NewRecord(req, c), true
}
return nil, false
}
+202
View File
@@ -0,0 +1,202 @@
/*
* 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 identifier
import (
"encoding/base64"
"net/http"
"golang.org/x/crypto/blake2b"
)
const (
consentCookieNamePrefix = "__Secure-KKTC" // Kopano Konnect Temorary Consent
stateCookieNamePrefix = "__Secure-KKTS" // Kopano Konnect Temporary State
)
func (i *Identifier) setLogonCookie(rw http.ResponseWriter, value string) error {
cookie := http.Cookie{
Name: i.logonCookieName,
Value: value,
Path: i.pathPrefix + "/identifier/_/",
Secure: true,
HttpOnly: true,
SameSite: i.logonCookieSameSite,
}
http.SetCookie(rw, &cookie)
return nil
}
func (i *Identifier) getLogonCookie(req *http.Request) (*http.Cookie, error) {
return req.Cookie(i.logonCookieName)
}
func (i *Identifier) removeLogonCookie(rw http.ResponseWriter) error {
cookie := http.Cookie{
Name: i.logonCookieName,
Path: i.pathPrefix + "/identifier/_/",
Secure: true,
HttpOnly: true,
SameSite: i.logonCookieSameSite,
Expires: farPastExpiryTime,
}
http.SetCookie(rw, &cookie)
return nil
}
func (i *Identifier) setConsentCookie(rw http.ResponseWriter, cr *ConsentRequest, value string) error {
name, err := i.getConsentCookieName(cr)
if err != nil {
return err
}
cookie := http.Cookie{
Name: name,
Value: value,
MaxAge: 60,
Path: i.pathPrefix + "/identifier/_/",
Secure: true,
HttpOnly: true,
SameSite: i.consentCookieSameSite,
}
http.SetCookie(rw, &cookie)
return nil
}
func (i *Identifier) getConsentCookie(req *http.Request, cr *ConsentRequest) (*http.Cookie, error) {
name, err := i.getConsentCookieName(cr)
if err != nil {
return nil, err
}
return req.Cookie(name)
}
func (i *Identifier) removeConsentCookie(rw http.ResponseWriter, req *http.Request, cr *ConsentRequest) error {
name, err := i.getConsentCookieName(cr)
if err != nil {
return nil
}
cookie := http.Cookie{
Name: name,
Path: i.pathPrefix + "/identifier/_/",
Secure: true,
HttpOnly: true,
SameSite: i.consentCookieSameSite,
Expires: farPastExpiryTime,
}
http.SetCookie(rw, &cookie)
return nil
}
func (i *Identifier) getConsentCookieName(cr *ConsentRequest) (string, error) {
// Consent cookie names are based on parameters in the request.
hasher, err := blake2b.New256(nil)
if err != nil {
return "", err
}
hasher.Write([]byte(cr.State))
hasher.Write([]byte("h"))
hasher.Write([]byte(cr.ClientID))
hasher.Write([]byte("e"))
hasher.Write([]byte(cr.RawRedirectURI))
hasher.Write([]byte("l"))
hasher.Write([]byte(cr.Ref))
hasher.Write([]byte("o"))
hasher.Write([]byte(cr.Nonce))
name := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
return consentCookieNamePrefix + "-" + name, nil
}
func (i *Identifier) setStateCookie(rw http.ResponseWriter, scope string, state string, value string) error {
name, err := i.getStateCookieName(state)
if err != nil {
return err
}
cookie := http.Cookie{
Name: name,
Value: value,
MaxAge: 600,
Path: i.pathPrefix + "/identifier/" + scope,
Secure: true,
HttpOnly: true,
SameSite: i.stateCookieSameSite,
}
http.SetCookie(rw, &cookie)
return nil
}
func (i *Identifier) getStateCookie(req *http.Request, state string) (*http.Cookie, error) {
name, err := i.getStateCookieName(state)
if err != nil {
return nil, err
}
return req.Cookie(name)
}
func (i *Identifier) removeStateCookie(rw http.ResponseWriter, req *http.Request, scope string, state string) error {
name, err := i.getStateCookieName(state)
if err != nil {
return nil
}
cookie := http.Cookie{
Name: name,
Path: i.pathPrefix + "/identifier/" + scope,
Secure: true,
HttpOnly: true,
SameSite: i.stateCookieSameSite,
Expires: farPastExpiryTime,
}
http.SetCookie(rw, &cookie)
return nil
}
func (i *Identifier) getStateCookieName(state string) (string, error) {
// State cookie names are based on the state value.
hasher, err := blake2b.New256(nil)
if err != nil {
return "", err
}
hasher.Write([]byte(state))
name := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
return stateCookieNamePrefix + "-" + name, nil
}
+27
View File
@@ -0,0 +1,27 @@
/*
* 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 identifier
const (
// FlowOIDC is the string value for the oidc flow.
FlowOIDC = "oidc"
// FlowOAuth is the string value for the oauth flow.
FlowOAuth = "oauth"
// FlowConsent is the string value for the consent flow.
FlowConsent = "consent"
)
+526
View File
@@ -0,0 +1,526 @@
/*
* 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 identifier
import (
"encoding/json"
"encoding/xml"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/sirupsen/logrus"
konnect "github.com/libregraph/lico"
"github.com/libregraph/lico/identity/authorities"
"github.com/libregraph/lico/utils"
)
func (i *Identifier) staticHandler(handler http.Handler, cache bool) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
addCommonResponseHeaders(rw.Header())
if cache {
rw.Header().Set("Cache-Control", "max-age=3153600, public")
} else {
rw.Header().Set("Cache-Control", "no-cache, max-age=0, public")
}
if strings.HasSuffix(req.URL.Path, "/") {
// Do not serve folder-ish resources.
i.ErrorPage(rw, http.StatusNotFound, "", "")
return
}
handler.ServeHTTP(rw, req)
})
}
func (i *Identifier) secureHandler(handler http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
var err error
// TODO(longsleep): Add support for X-Forwareded-Host with trusted proxy.
// NOTE: this does not protect from DNS rebinding. Protection for that
// should be added at the frontend proxy.
requiredHost := req.Host
if host, port, splitErr := net.SplitHostPort(requiredHost); splitErr == nil {
if port == "443" {
// Ignore the port 443 as it is the default port and it is
// usually not part of any of the urls. It might be in the
// request for HTTP/3 requests.
requiredHost = host
}
}
// This follows https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet
for {
if req.Header.Get("Kopano-Konnect-XSRF") != "1" {
err = fmt.Errorf("missing xsrf header")
break
}
origin := req.Header.Get("Origin")
referer := req.Header.Get("Referer")
// Require either Origin and Referer header.
// NOTE(longsleep): Firefox does not send Origin header for POST
// requests when on the same domain - this is fuck (tm). See
// https://bugzilla.mozilla.org/show_bug.cgi?id=446344 for reference.
if origin == "" && referer == "" {
err = fmt.Errorf("missing origin or referer header")
break
}
if origin != "" {
originURL, urlParseErr := url.Parse(origin)
if urlParseErr != nil {
err = fmt.Errorf("invalid origin value: %v", urlParseErr)
break
}
if originURL.Host != requiredHost {
err = fmt.Errorf("origin does not match request URL")
break
}
} else if referer != "" {
refererURL, urlParseErr := url.Parse(referer)
if urlParseErr != nil {
err = fmt.Errorf("invalid referer value: %v", urlParseErr)
break
}
if refererURL.Host != requiredHost {
err = fmt.Errorf("referer does not match request URL")
break
}
} else {
i.logger.WithFields(logrus.Fields{
"host": requiredHost,
"user-agent": req.UserAgent(),
}).Warn("identifier HTTP request is insecure with no Origin and Referer")
}
handler.ServeHTTP(rw, req)
return
}
if err != nil {
i.logger.WithError(err).WithFields(logrus.Fields{
"host": requiredHost,
"referer": req.Referer(),
"user-agent": req.UserAgent(),
"origin": req.Header.Get("Origin"),
}).Warn("rejecting identifier HTTP request")
}
i.ErrorPage(rw, http.StatusBadRequest, "", "")
})
}
func (i *Identifier) handleIdentifier(rw http.ResponseWriter, req *http.Request) {
addCommonResponseHeaders(rw.Header())
addNoCacheResponseHeaders(rw.Header())
err := req.ParseForm()
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request")
return
}
switch req.Form.Get("flow") {
case FlowOIDC, FlowOAuth, "":
if req.Form.Get("identifier") != MustBeSignedIn {
// Check if there is a default authority, if so use that.
authority := i.authorities.Default(req.Context())
if authority != nil {
switch authority.AuthorityType {
case authorities.AuthorityTypeOIDC:
i.writeOAuth2Start(rw, req, authority)
case authorities.AuthorityTypeSAML2:
i.writeSAML2Start(rw, req, authority)
default:
i.ErrorPage(rw, http.StatusNotImplemented, "", "unknown authority type")
}
return
}
}
}
// Show default.
i.writeWebappIndexHTML(rw, req)
}
func (i *Identifier) handleLogon(rw http.ResponseWriter, req *http.Request) {
decoder := json.NewDecoder(req.Body)
var r LogonRequest
err := decoder.Decode(&r)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode logon request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request JSON")
return
}
var user *IdentifiedUser
response := &LogonResponse{
State: r.State,
}
addNoCacheResponseHeaders(rw.Header())
record := NewRecord(req, i.Config.Config)
if r.Hello != nil {
err = r.Hello.parse()
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to parse logon request hello")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to parse request values")
return
}
record.HelloRequest = r.Hello
}
req = req.WithContext(NewRecordContext(konnect.NewRequestContext(req.Context(), req), record))
// Params is an array like this [$username, $password, $mode], defining a
// extensible way to extend login modes over time. The minimal length of
// the params array is 1 with only [$username]. Second field is the password
// but its interpretation depends on the third field ($mode). The rest of the
// fields are mode specific.
params := r.Params
for {
paramSize := len(params)
if paramSize == 0 {
i.ErrorPage(rw, http.StatusBadRequest, "", "params required")
break
}
if paramSize >= 3 && params[1] == "" && params[2] == ModeLogonUsernameEmptyPasswordCookie {
// Special mode to allow when same user is logged in via cookie. This
// is used in the select account page logon flow with empty password.
identifiedUser, cookieErr := i.GetUserFromLogonCookie(req.Context(), req, 0, true)
if cookieErr != nil {
i.logger.WithError(cookieErr).Debugln("identifier failed to decode logon cookie in logon request")
}
if identifiedUser != nil {
if identifiedUser.Username() == params[0] {
user = identifiedUser
break
}
}
}
audience := ""
if r.Hello != nil {
audience = r.Hello.ClientID
}
if paramSize < 3 {
// Unsupported logon mode.
break
}
if params[1] == "" {
// Empty password, stop here - never allowed in any mode.
break
}
switch params[2] {
case ModeLogonUsernamePassword:
// Username and password validation mode.
logonedUser, logonErr := i.logonUser(req.Context(), audience, params[0], params[1])
if logonErr != nil {
i.logger.WithError(logonErr).Errorln("identifier failed to logon with backend")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to logon")
return
}
user = logonedUser
default:
i.logger.Debugln("identifier unknown logon mode: %v", params[2])
}
break
}
if user == nil || user.Subject() == "" {
rw.Header().Set("Kopano-Konnect-State", response.State)
rw.WriteHeader(http.StatusNoContent)
return
}
// Get user meta data.
// TODO(longsleep): This is an additional request to the backend. This
// should be avoided. Best would be if the backend would return everything
// in one shot (TODO in core).
err = i.updateUser(req.Context(), user, nil)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to update user data in logon request")
}
// Set logon time.
user.logonAt = time.Now()
if r.Hello != nil {
hello, errHello := i.writeHelloResponse(rw, req, r.Hello, user)
if errHello != nil {
i.logger.WithError(errHello).Debugln("rejecting identifier logon request")
i.ErrorPage(rw, http.StatusBadRequest, "", errHello.Error())
return
}
if !hello.Success {
rw.Header().Set("Kopano-Konnect-State", response.State)
rw.WriteHeader(http.StatusNoContent)
return
}
response.Hello = hello
}
err = i.SetUserToLogonCookie(req.Context(), rw, user)
if err != nil {
i.logger.WithError(err).Errorln("failed to serialize logon ticket")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to serialize logon ticket")
return
}
response.Success = true
err = utils.WriteJSON(rw, http.StatusOK, response, "")
if err != nil {
i.logger.WithError(err).Errorln("logon request failed writing response")
}
}
func (i *Identifier) handleLogoff(rw http.ResponseWriter, req *http.Request) {
decoder := json.NewDecoder(req.Body)
var r StateRequest
err := decoder.Decode(&r)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode logoff request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request JSON")
return
}
addNoCacheResponseHeaders(rw.Header())
ctx := req.Context()
u, err := i.GetUserFromLogonCookie(ctx, req, 0, false)
if err != nil {
i.logger.WithError(err).Warnln("identifier logoff failed to get logon from ticket")
}
err = i.UnsetLogonCookie(ctx, u, rw)
if err != nil {
i.logger.WithError(err).Errorln("identifier failed to set logoff ticket")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to set logoff ticket")
return
}
response := &StateResponse{
State: r.State,
Success: true,
}
err = utils.WriteJSON(rw, http.StatusOK, response, "")
if err != nil {
i.logger.WithError(err).Errorln("logoff request failed writing response")
}
}
func (i *Identifier) handleConsent(rw http.ResponseWriter, req *http.Request) {
decoder := json.NewDecoder(req.Body)
var r ConsentRequest
err := decoder.Decode(&r)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode consent request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request JSON")
return
}
addNoCacheResponseHeaders(rw.Header())
consent := &Consent{
Allow: r.Allow,
}
if r.Allow {
consent.RawScope = r.RawScope
}
err = i.SetConsentToConsentCookie(req.Context(), rw, &r, consent)
if err != nil {
i.logger.WithError(err).Errorln("failed to serialize consent ticket")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to serialize consent ticket")
return
}
if !r.Allow {
rw.Header().Set("Kopano-Konnect-State", r.State)
rw.WriteHeader(http.StatusNoContent)
return
}
response := &StateResponse{
State: r.State,
Success: true,
}
err = utils.WriteJSON(rw, http.StatusOK, response, "")
if err != nil {
i.logger.WithError(err).Errorln("logoff request failed writing response")
}
}
func (i *Identifier) handleHello(rw http.ResponseWriter, req *http.Request) {
decoder := json.NewDecoder(req.Body)
var r HelloRequest
err := decoder.Decode(&r)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode hello request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request JSON")
return
}
err = r.parse()
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to parse hello request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to parse request values")
return
}
addNoCacheResponseHeaders(rw.Header())
response, err := i.writeHelloResponse(rw, req, &r, nil)
if err != nil {
i.logger.WithError(err).Debugln("rejecting identifier hello request")
i.ErrorPage(rw, http.StatusBadRequest, "", err.Error())
return
}
err = utils.WriteJSON(rw, http.StatusOK, response, "")
if err != nil {
i.logger.WithError(err).Errorln("hello request failed writing response")
}
}
func (i *Identifier) handleTrampolin(rw http.ResponseWriter, req *http.Request) {
if !strings.HasSuffix(req.URL.Path, ".js") {
err := req.ParseForm()
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode trampolin request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
return
}
sd, err := i.GetStateFromStateCookie(req.Context(), rw, req, "trampolin", req.Form.Get("state"))
if err != nil {
i.ErrorPage(rw, http.StatusBadRequest, "", err.Error())
return
}
if sd == nil || sd.Trampolin == nil {
i.ErrorPage(rw, http.StatusBadRequest, "", "no state")
return
}
scope := sd.Trampolin.Scope
uri, _ := url.Parse(sd.Trampolin.URI)
sd.Trampolin = nil
err = i.SetStateToStateCookie(req.Context(), rw, scope, sd)
if err != nil {
i.logger.WithError(err).Errorln("failed to write trampolin state cookie")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to write trampolin state cookie")
return
}
i.writeTrampolinHTML(rw, req, uri)
} else {
i.writeTrampolinScript(rw, req)
}
}
func (i *Identifier) handleOAuth2Start(rw http.ResponseWriter, req *http.Request) {
err := req.ParseForm()
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode oauth2 start request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
return
}
var authority *authorities.Details
if authorityID := req.Form.Get("authority_id"); authorityID != "" {
authority, _ = i.authorities.Lookup(req.Context(), authorityID)
}
i.writeOAuth2Start(rw, req, authority)
}
func (i *Identifier) handleOAuth2Cb(rw http.ResponseWriter, req *http.Request) {
err := req.ParseForm()
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode oauth2 cb request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
return
}
i.writeOAuth2Cb(rw, req)
}
func (i *Identifier) handleSAML2Metadata(rw http.ResponseWriter, req *http.Request) {
authorityDetails := i.authorities.Default(req.Context())
if authorityDetails == nil || authorityDetails.AuthorityType != authorities.AuthorityTypeSAML2 {
i.ErrorPage(rw, http.StatusNotFound, "", "saml not configured")
return
}
metadata := authorityDetails.Metadata()
if metadata == nil {
i.ErrorPage(rw, http.StatusNotFound, "", "saml has no meta data")
return
}
buf, _ := xml.MarshalIndent(metadata, "", " ")
rw.Header().Set("Content-Type", "application/samlmetadata+xml")
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(xml.Header))
rw.Write(buf)
}
func (i *Identifier) handleSAML2AssertionConsumerService(rw http.ResponseWriter, req *http.Request) {
err := req.ParseForm()
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode saml2 acs request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
return
}
i.writeSAML2AssertionConsumerService(rw, req)
}
func (i *Identifier) handleSAML2SingleLogoutService(rw http.ResponseWriter, req *http.Request) {
err := req.ParseForm()
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode saml2 slo request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
return
}
if _, ok := req.Form["SAMLRequest"]; ok {
i.writeSAMLSingleLogoutServiceRequest(rw, req)
} else if _, ok := req.Form["SAMLResponse"]; ok {
i.writeSAMLSingleLogoutServiceResponse(rw, req)
} else {
i.ErrorPage(rw, http.StatusBadRequest, "", "neither SAMLRequest nor SAMLResponse parameter found")
}
}
+774
View File
@@ -0,0 +1,774 @@
/*
* 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 identifier
import (
"bytes"
"context"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os"
"strings"
"time"
mapset "github.com/deckarep/golang-set"
"github.com/go-jose/go-jose/v3"
"github.com/go-jose/go-jose/v3/jwt"
"github.com/gorilla/mux"
"github.com/longsleep/rndm"
"github.com/sirupsen/logrus"
"github.com/libregraph/oidc-go"
konnect "github.com/libregraph/lico"
"github.com/libregraph/lico/identifier/backends"
"github.com/libregraph/lico/identifier/meta"
"github.com/libregraph/lico/identifier/meta/scopes"
"github.com/libregraph/lico/identity"
"github.com/libregraph/lico/identity/authorities"
"github.com/libregraph/lico/identity/clients"
"github.com/libregraph/lico/managers"
"github.com/libregraph/lico/utils"
)
// audienceMarker defines the value which gets included in logon cookies. Valid
// logon cookies must have the first value of this list in their audience claim.
// Increment this value whenever logon cookie claims and format changes in
// non-backwards compatible ways. User will have to sign in again to get a new
// cookie.
var audienceMarker = jwt.Audience([]string{"2019012201"})
// Identifier defines a identification login area with its endpoints using
// a Kopano Core server as backend logon provider.
type Identifier struct {
Config *Config
baseURI *url.URL
pathPrefix string
staticFolder string
scopesConf string
webappIndexHTML []byte
logonCookieName string
logonCookieSameSite http.SameSite
consentCookieSameSite http.SameSite
stateCookieSameSite http.SameSite
authorizationEndpointURI *url.URL
signedOutEndpointURI *url.URL
oauth2CbEndpointURI *url.URL
encrypter jose.Encrypter
recipient *jose.Recipient
backend backends.Backend
clients *clients.Registry
authorities *authorities.Registry
meta *meta.Meta
defaultBannerLogo *string
onSetLogonCallbacks []func(ctx context.Context, rw http.ResponseWriter, user identity.User) error
onUnsetLogonCallbacks []func(ctx context.Context, rw http.ResponseWriter) error
logger logrus.FieldLogger
router *mux.Router
}
// NewIdentifier returns a new Identifier.
func NewIdentifier(c *Config) (*Identifier, error) {
staticFolder := c.StaticFolder
var webappIndexHTML = make([]byte, 0)
if !c.WebAppDisabled {
fn := staticFolder + "/index.html"
if _, statErr := os.Stat(fn); os.IsNotExist(statErr) {
return nil, fmt.Errorf("identifier client index.html not found: %w", statErr)
}
readData, readErr := ioutil.ReadFile(fn)
if readErr != nil {
return nil, fmt.Errorf("identifier failed to read client index.html: %w", readErr)
}
webappIndexHTML = bytes.Replace(readData, []byte("__PATH_PREFIX__"), []byte(c.PathPrefix), 1)
}
oauth2CbEndpointURI, _ := url.Parse(c.BaseURI.String())
oauth2CbEndpointURI.Path = c.PathPrefix + "/identifier/oauth2/cb"
i := &Identifier{
Config: c,
baseURI: c.BaseURI,
pathPrefix: c.PathPrefix,
staticFolder: staticFolder,
scopesConf: c.ScopesConf,
webappIndexHTML: webappIndexHTML,
logonCookieName: c.LogonCookieName,
logonCookieSameSite: c.LogonCookieSameSite,
consentCookieSameSite: c.ConsentCookieSameSite,
stateCookieSameSite: c.StateCookieSameSite,
authorizationEndpointURI: c.AuthorizationEndpointURI,
signedOutEndpointURI: c.SignedOutEndpointURI,
oauth2CbEndpointURI: oauth2CbEndpointURI,
backend: c.Backend,
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),
logger: c.Config.Logger,
}
var err error
i.meta = &meta.Meta{}
i.meta.Scopes, err = scopes.NewScopesFromFile(i.scopesConf, i.logger)
if err != nil {
return nil, err
}
if c.DefaultBannerLogo != nil {
defaultBannerLogo, err := encodeImageAsDataURL(c.DefaultBannerLogo)
if err != nil {
return nil, fmt.Errorf("failed to encode default banner logo: %w", err)
}
i.defaultBannerLogo = &defaultBannerLogo
}
i.meta.Scopes.Extend(c.Backend.ScopesMeta())
return i, nil
}
// RegisterManagers registers the provided managers,
func (i *Identifier) RegisterManagers(mgrs *managers.Managers) error {
i.clients = mgrs.Must("clients").(*clients.Registry)
i.authorities = mgrs.Must("authorities").(*authorities.Registry)
if service, ok := i.backend.(managers.ServiceUsesManagers); ok {
err := service.RegisterManagers(mgrs)
if err != nil {
return err
}
}
return nil
}
// AddRoutes adds the endpoint routes of the accociated Identifier to the
// provided router with the provided context.
func (i *Identifier) AddRoutes(ctx context.Context, router *mux.Router) {
r := router.PathPrefix(i.pathPrefix).Subrouter()
r.PathPrefix("/static/").Handler(i.staticHandler(http.StripPrefix(i.pathPrefix, http.FileServer(http.Dir(i.staticFolder))), true))
r.Handle("/service-worker.js", i.staticHandler(http.StripPrefix(i.pathPrefix, http.FileServer(http.Dir(i.staticFolder))), false))
r.Handle("/identifier", http.HandlerFunc(i.handleIdentifier)).Methods(http.MethodGet).Name("index")
r.Handle("/chooseaccount", i).Methods(http.MethodGet).Name("chooseaccount")
r.Handle("/consent", i).Methods(http.MethodGet).Name("consent")
r.Handle("/welcome", i).Methods(http.MethodGet).Name("welcome")
r.Handle("/goodbye", i).Methods(http.MethodGet).Name("goodbye")
r.Handle("/index.html", i).Methods(http.MethodGet) // For service worker.
r.Handle("/identifier/_/logon", i.secureHandler(http.HandlerFunc(i.handleLogon))).Methods(http.MethodPost)
r.Handle("/identifier/_/logoff", i.secureHandler(http.HandlerFunc(i.handleLogoff))).Methods(http.MethodPost)
r.Handle("/identifier/_/hello", i.secureHandler(http.HandlerFunc(i.handleHello))).Methods(http.MethodPost)
r.Handle("/identifier/_/consent", i.secureHandler(http.HandlerFunc(i.handleConsent))).Methods(http.MethodPost)
r.Handle("/identifier/oauth2/start", http.HandlerFunc(i.handleOAuth2Start)).Methods(http.MethodGet).Name("oauth2/start")
r.Handle("/identifier/oauth2/cb", http.HandlerFunc(i.handleOAuth2Cb)).Methods(http.MethodGet).Name("oauth2/cb")
r.Handle("/identifier/saml2/metadata", http.HandlerFunc(i.handleSAML2Metadata))
r.Handle("/identifier/saml2/acs", http.HandlerFunc(i.handleSAML2AssertionConsumerService)).Methods(http.MethodPost).Name("saml2/acs")
r.Handle("/identifier/_/saml2/slo", http.HandlerFunc(i.handleSAML2SingleLogoutService)).Methods(http.MethodGet).Name("saml2/slo")
r.Handle("/identifier/trampolin", http.HandlerFunc(i.handleTrampolin)).Methods(http.MethodGet).Name("trampolin")
r.Handle("/identifier/trampolin/trampolin.js", http.HandlerFunc(i.handleTrampolin)).Methods(http.MethodGet)
i.router = r
if i.backend != nil {
i.backend.RunWithContext(ctx)
}
}
// ServeHTTP implements the http.Handler interface.
func (i *Identifier) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
addCommonResponseHeaders(rw.Header())
addNoCacheResponseHeaders(rw.Header())
// Show default.
i.writeWebappIndexHTML(rw, req)
}
// SetKey sets the provided key for the accociated identifier.
func (i *Identifier) SetKey(key []byte) error {
var ce jose.ContentEncryption
var algo jose.KeyAlgorithm
switch len(key) {
case 16:
ce = jose.A128GCM
algo = jose.A128GCMKW
case 24:
ce = jose.A192GCM
algo = jose.A192GCMKW
case 32:
ce = jose.A256GCM
algo = jose.A256GCMKW
default:
return fmt.Errorf("identifier invalid encryption key size. Need 16, 24 or 32 bytes")
}
if len(key) < 32 {
i.logger.Warnf("identifier using encryption key size with %d bytes which is below 32 bytes", len(key))
} else {
i.logger.WithField("security", fmt.Sprintf("%s:%s", ce, algo)).Infoln("identifier set up")
}
recipient := jose.Recipient{
Algorithm: algo,
KeyID: "",
Key: key,
}
encrypter, err := jose.NewEncrypter(
ce,
recipient,
nil,
)
if err != nil {
return err
}
i.encrypter = encrypter
i.recipient = &recipient
return nil
}
// ErrorPage writes a HTML error page to the provided ResponseWriter.
func (i *Identifier) ErrorPage(rw http.ResponseWriter, code int, title string, message string) {
utils.WriteErrorPage(rw, code, title, message)
}
// SetUserToLogonCookie serializes the provided user into an encrypted string
// and sets it as cookie on the provided http.ResponseWriter.
func (i *Identifier) SetUserToLogonCookie(ctx context.Context, rw http.ResponseWriter, user *IdentifiedUser) error {
loggedOn, logonAt := user.LoggedOn()
if !loggedOn {
return fmt.Errorf("refused to set cookie for not logged on user")
}
// Add standard claims.
claims := jwt.Claims{
Issuer: user.BackendName(),
Audience: audienceMarker,
Subject: user.Subject(),
IssuedAt: jwt.NewNumericDate(logonAt),
}
// Add expiration, if set.
if user.expiresAfter != nil {
claims.Expiry = jwt.NewNumericDate(*user.expiresAfter)
}
// Additional claims.
userClaims := map[string]interface{}(user.Claims())
if sessionRef := user.SessionRef(); sessionRef != nil {
userClaims[SessionIDClaim] = *sessionRef
}
if logonRef := user.LogonRef(); logonRef != nil {
userClaims[LogonRefClaim] = *logonRef
}
if externalAuthorityID := user.ExternalAuthorityID(); externalAuthorityID != nil {
userClaims[ExternalAuthorityIDClaim] = *externalAuthorityID
}
if lockedScopes := user.LockedScopes(); lockedScopes != nil {
userClaims[LockedScopesClaim] = strings.Join(lockedScopes, " ")
}
// Serialize and encrypt cookie value.
serialized, err := jwt.Encrypted(i.encrypter).Claims(claims).Claims(userClaims).CompactSerialize()
if err != nil {
return err
}
// Set cookie.
err = i.setLogonCookie(rw, serialized)
if err != nil {
return err
}
// Trigger callbacks.
for _, f := range i.onSetLogonCallbacks {
err = f(ctx, rw, user)
if err != nil {
return err
}
}
return nil
}
// UnsetLogonCookie adds cookie remove headers to the provided http.ResponseWriter
// effectively implementing logout.
func (i *Identifier) UnsetLogonCookie(ctx context.Context, user *IdentifiedUser, rw http.ResponseWriter) error {
// Remove cookie.
err := i.removeLogonCookie(rw)
if err != nil {
return err
}
// Destroy backend user session if any.
if user != nil {
if sessionRef := user.SessionRef(); sessionRef != nil {
err = i.backend.DestroySession(ctx, sessionRef)
if err != nil {
i.logger.WithError(err).Warnln("failed to destroy session on unset logon cookie")
}
}
}
// Trigger callbacks.
for _, f := range i.onUnsetLogonCallbacks {
err = f(ctx, rw)
if err != nil {
return err
}
}
return nil
}
// EndSession begins the process to end the session either directly or indirectly
// based on the provided user. It optionally returns an uri which shall be used
// as redirection target or an error.
func (i *Identifier) EndSession(ctx context.Context, user *IdentifiedUser, rw http.ResponseWriter, postRedirectURI *url.URL, state string) (*url.URL, error) {
err := i.UnsetLogonCookie(ctx, user, rw)
if err != nil {
return nil, err
}
var uri *url.URL
if user.externalAuthority != nil && user.externalAuthority.EndSessionEnabled {
// Generate state and set state cookie with postRedirectURI.
if state == "" {
state = rndm.GenerateRandomString(32)
}
sd := &StateData{
State: state,
Mode: StateModeEndSession,
Ref: user.externalAuthority.ID,
}
var extra map[string]interface{}
uri, extra, err = user.externalAuthority.MakeRedirectEndSessionRequestURL(user.LogonRef(), sd.State)
if err != nil {
return nil, err
}
sd.Extra = extra
if postRedirectURI != nil && postRedirectURI.String() != "" {
sd.RawQuery = postRedirectURI.String()
}
var scope string
switch user.externalAuthority.AuthorityType {
case authorities.AuthorityTypeOIDC:
// Inject post logout url target.
cb, _ := i.router.GetRoute("oauth2/cb").URL()
next, _ := url.Parse(i.baseURI.String())
next.Path = cb.Path
query := uri.Query()
query.Set("post_logout_redirect_uri", next.String())
uri.RawQuery = query.Encode()
// Redirect using trampolin, to ensure origin checks of external
// authority can pass.
sd.Trampolin = &TrampolinData{
URI: uri.String(),
Scope: "oauth2/cb",
}
scope = "trampolin"
uri, _ = i.router.GetRoute("trampolin").URL()
query = make(url.Values)
query.Add("state", sd.State)
uri.RawQuery = query.Encode()
case authorities.AuthorityTypeSAML2:
scope = "_/saml2/slo"
}
if scope != "" {
err = i.SetStateToStateCookie(ctx, rw, scope, sd)
if err != nil {
return nil, fmt.Errorf("failed to set saml2 slo state cookie: %w", err)
}
}
}
return uri, nil
}
// GetUserFromLogonCookie looks up the associated cookie name from the provided
// request, parses it and returns the user containing the information found in
// the coookie payload data.
func (i *Identifier) GetUserFromLogonCookie(ctx context.Context, req *http.Request, maxAge time.Duration, refreshSession bool) (*IdentifiedUser, error) {
cookie, err := i.getLogonCookie(req)
if err != nil {
if err == http.ErrNoCookie {
return nil, nil
}
return nil, err
}
// Decrypt and parse cookie value.
token, err := jwt.ParseEncrypted(cookie.Value)
if err != nil {
return nil, err
}
// Parse claims.
var claims jwt.Claims
var userClaims map[string]interface{}
if claimsErr := token.Claims(i.recipient.Key, &claims, &userClaims); claimsErr != nil {
return nil, claimsErr
}
// Validate claims.
if claimsErr := claims.Validate(jwt.Expected{
// Ignore cookie, when issuer does not match our backend name. This usually
// means that konnect was reconfigured. Users need to sign in again.
Issuer: i.backend.Name(),
// Ignore cookie, when audience marker does not match. This happens
// for cookies from an older version of konnect. Users need to sign in again.
Audience: jwt.Audience{audienceMarker[0]},
}); claimsErr != nil {
i.logger.WithError(claimsErr).Debugln("logon token claims validation failed")
return nil, nil
}
if claims.Subject == "" {
return nil, fmt.Errorf("invalid subject in logon token")
}
if userClaims == nil {
return nil, fmt.Errorf("invalid user claims in logon token")
}
// New user with details from claims.
user := &IdentifiedUser{
sub: claims.Subject,
// TODO(longsleep): It is not verified here that the user still exists at
// our current backend. We still assign the backend happily here - probably
// needs some sort of veritification / lookup.
backend: i.backend,
logonAt: claims.IssuedAt.Time(),
}
if claims.Expiry != nil {
expiresAfter := claims.Expiry.Time()
user.expiresAfter = &expiresAfter
}
loggedOn, logonAt := user.LoggedOn()
if !loggedOn {
// Ignore logons which are not valid.
return nil, nil
}
if maxAge > 0 {
if logonAt.Add(maxAge).Before(time.Now()) {
// Ignore logon as it is no longer valid within maxAge.
return nil, nil
}
}
// Get specific data from claims.
if v := userClaims[SessionIDClaim]; v != nil {
sessionRef := v.(string)
if sessionRef != "" {
// Remember session ref in user.
user.sessionRef = &sessionRef
// Ensure the session is still valid, by refreshing it.
if refreshSession {
err = i.backend.RefreshSession(ctx, user.Subject(), &sessionRef, userClaims)
if err != nil {
// Ignore logons which fail session refresh.
return nil, nil
}
}
}
}
if v := userClaims[LogonRefClaim]; v != nil {
logonRef := v.(string)
if logonRef != "" {
// Remember logon ref in user.
user.logonRef = &logonRef
}
}
if v := userClaims[ExternalAuthorityIDClaim]; v != nil {
externalAuthorityID := v.(string)
if externalAuthorityID != "" {
authority, err := i.authorities.Lookup(ctx, externalAuthorityID)
if err != nil {
// Ignore logons which have set an unknown external authority.
return nil, nil
}
// TODO(longsleep): Check if authority is actually enabled. For now
// we check if it is ready.
if !authority.IsReady() {
// Ignore logons which have sent an authority which is not ready.
return nil, nil
}
user.externalAuthority = authority
}
}
if v := userClaims[LockedScopesClaim]; v != nil {
lockedScopes := v.(string)
if lockedScopes != "" {
user.lockedScopes = strings.Split(lockedScopes, " ")
}
}
// Fill additional claim.
user.claims = make(map[string]interface{})
for k, v := range userClaims {
switch k {
case konnect.IdentifiedUsernameClaim:
user.username = v.(string)
case konnect.IdentifiedDisplayNameClaim:
user.displayName = v.(string)
case SessionIDClaim:
// Already handled above.
continue
case LogonRefClaim:
// Already handled above.
continue
case ExternalAuthorityIDClaim:
// Already handled above.
continue
case LockedScopesClaim:
// Already handled above.
continue
case ObsoleteUserClaimsClaim:
// Keep and ignore for history reasons.
continue
case oidc.AudienceClaim, oidc.IssuedAtClaim, oidc.ExpirationClaim, oidc.SubjectIdentifierClaim, oidc.IssuerIdentifierClaim:
// Ignore default OIDC claims when resurrecting claims data.
continue
default:
// Add the rest.
user.claims[k] = v
}
}
return user, nil
}
// GetUserFromID looks up the user identified by the provided userID by
// requesting the associated backend.
func (i *Identifier) GetUserFromID(ctx context.Context, userID string, sessionRef *string, requestedScopes map[string]bool) (*IdentifiedUser, error) {
user, err := i.backend.GetUser(ctx, userID, sessionRef, requestedScopes)
if err != nil {
return nil, err
}
if user == nil {
return nil, nil
}
// XXX(longsleep): This is quite crappy. Move IdentifiedUser to a package
// which can be imported by backends so they directly can return that shit.
identifiedUser := &IdentifiedUser{
sub: user.Subject(),
username: user.Username(),
backend: i.backend,
sessionRef: sessionRef,
claims: user.BackendClaims(),
scopes: user.BackendScopes(),
lockedScopes: user.RequiredScopes(),
}
if userWithEmail, ok := user.(identity.UserWithEmail); ok {
identifiedUser.email = userWithEmail.Email()
identifiedUser.emailVerified = userWithEmail.EmailVerified()
}
if userWithProfile, ok := user.(identity.UserWithProfile); ok {
identifiedUser.displayName = userWithProfile.Name()
identifiedUser.familyName = userWithProfile.FamilyName()
identifiedUser.givenName = userWithProfile.GivenName()
}
if userWithID, ok := user.(identity.UserWithID); ok {
identifiedUser.id = userWithID.ID()
}
if userWithUniqueID, ok := user.(identity.UserWithUniqueID); ok {
identifiedUser.uid = userWithUniqueID.UniqueID()
}
return identifiedUser, nil
}
// SetConsentToConsentCookie serializses the provided Consent using the provided
// ConsentRequest and sets it as cookie on the provided ReponseWriter.
func (i *Identifier) SetConsentToConsentCookie(ctx context.Context, rw http.ResponseWriter, cr *ConsentRequest, consent *Consent) error {
serialized, err := jwt.Encrypted(i.encrypter).Claims(consent).CompactSerialize()
if err != nil {
return err
}
return i.setConsentCookie(rw, cr, serialized)
}
// GetConsentFromConsentCookie extract consent information for the provided
// request and the provide state.
func (i *Identifier) GetConsentFromConsentCookie(ctx context.Context, rw http.ResponseWriter, req *http.Request, state string) (*Consent, error) {
if state == "" {
return nil, nil
}
cr := &ConsentRequest{
State: state,
ClientID: req.Form.Get("client_id"),
RawRedirectURI: req.Form.Get("redirect_uri"),
Ref: req.Form.Get("state"),
Nonce: req.Form.Get("nonce"),
}
cookie, err := i.getConsentCookie(req, cr)
if err != nil {
if err == http.ErrNoCookie {
return nil, nil
}
return nil, err
}
// Directly remove the cookie again after we used it.
i.removeConsentCookie(rw, req, cr)
token, err := jwt.ParseEncrypted(cookie.Value)
if err != nil {
return nil, err
}
var consent Consent
if err = token.Claims(i.recipient.Key, &consent); err != nil {
return nil, err
}
return &consent, nil
}
// SetStateToStateCookie serializses the provided StateRequest and sets it
// as cookie on the provided ReponseWriter.
func (i *Identifier) SetStateToStateCookie(ctx context.Context, rw http.ResponseWriter, scope string, sd *StateData) error {
serialized, err := jwt.Encrypted(i.encrypter).Claims(sd).CompactSerialize()
if err != nil {
return err
}
return i.setStateCookie(rw, scope, sd.State, serialized)
}
// GetStateFromStateCookie extracts state information for the provided
// request using the provided scope and state.
func (i *Identifier) GetStateFromStateCookie(ctx context.Context, rw http.ResponseWriter, req *http.Request, scope string, state string) (*StateData, error) {
if state == "" {
return nil, nil
}
cookie, err := i.getStateCookie(req, state)
if err != nil {
if err == http.ErrNoCookie {
return nil, nil
}
return nil, err
}
// Directly remove the cookie again after we used it.
i.removeStateCookie(rw, req, scope, state)
token, err := jwt.ParseEncrypted(cookie.Value)
if err != nil {
return nil, err
}
sd := &StateData{}
if err = token.Claims(i.recipient.Key, sd); err != nil {
return nil, err
}
if sd.State != state {
return nil, fmt.Errorf("state mismatch")
}
return sd, nil
}
// Name returns the active identifiers backend's name.
func (i *Identifier) Name() string {
return i.backend.Name()
}
// ScopesSupported return the scopes supported by the accociated Identifier.
func (i *Identifier) ScopesSupported() []string {
scopes := mapset.NewThreadUnsafeSet()
for scope := range i.meta.Scopes.Definitions {
scopes.Add(scope)
}
for _, scope := range i.backend.ScopesSupported() {
scopes.Add(scope)
}
supportedScopes := make([]string, 0)
it := scopes.Iterator()
for scope := range it.C {
supportedScopes = append(supportedScopes, scope.(string))
}
return supportedScopes
}
// OnSetLogon implements a way to register hooks whenever logon information is
// set by the accociated Identifier.
func (i *Identifier) OnSetLogon(cb func(ctx context.Context, rw http.ResponseWriter, user identity.User) error) error {
i.onSetLogonCallbacks = append(i.onSetLogonCallbacks, cb)
return nil
}
// OnUnsetLogon implements a way to register hooks whenever logon information is
// set by the accociated Identifier.
func (i *Identifier) OnUnsetLogon(cb func(ctx context.Context, rw http.ResponseWriter) error) error {
i.onUnsetLogonCallbacks = append(i.onUnsetLogonCallbacks, cb)
return nil
}
func (i *Identifier) absoluteURLForRoute(name string) (*url.URL, error) {
uri, _ := url.Parse(i.Config.BaseURI.String())
route := i.router.Get(name)
path, err := route.URL()
if err != nil {
return nil, err
}
uri.Path = path.Path
return uri, nil
}
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head data-kopano-build="%VITE_KOPANO_BUILD%">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#ffffff">
<link rel="shortcut icon" href="/static/favicon.ico" type="image/x-icon">
<meta property="csp-nonce" content="__CSP_NONCE__">
<title>Sign in to your account</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="bg">
<div></div>
</div>
<div id="root" data-path-prefix="__PATH_PREFIX__"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright 2021 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 meta
// Branding is a container to hold identifier branding meta data.
type Branding struct {
BannerLogo *string `json:"bannerLogo,omitempty"`
SignInPageText *string `json:"signinPageText,omitempty"`
UsernameHintText *string `json:"usernameHintText,omitempty"`
SignInPageLogoURI *string `json:"signinPageLogoURI,omitempty"`
Locales []string `json:"locales,omitempty"`
}
+28
View File
@@ -0,0 +1,28 @@
/*
* 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 meta
import (
"github.com/libregraph/lico/identifier/meta/scopes"
)
// Meta is a container to hold identifier meta data which can be requested by
// clients.
type Meta struct {
Scopes *scopes.Scopes `json:"scopes"`
}
@@ -0,0 +1,25 @@
/*
* 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 scopes
// A Definition contains the meta data for a single scope.
type Definition struct {
Priority int `json:"priority" yaml:"priority"`
Description string `json:"description,omitempty" yaml:"description"`
ID string `json:"id,omitempty"`
}
@@ -0,0 +1,161 @@
/*
* 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 scopes
import (
"io/ioutil"
"github.com/libregraph/oidc-go"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v2"
konnect "github.com/libregraph/lico"
)
const (
scopeAliasBasic = "basic"
scopeUnknown = "unknown"
)
const (
priorityBasic = 20
priorityOfflineAccess = 10
)
var defaultScopesMap = map[string]string{
oidc.ScopeOpenID: scopeAliasBasic,
oidc.ScopeEmail: scopeAliasBasic,
oidc.ScopeProfile: scopeAliasBasic,
konnect.ScopeNumericID: scopeAliasBasic,
konnect.ScopeUniqueUserID: scopeAliasBasic,
konnect.ScopeRawSubject: scopeAliasBasic,
}
var defaultScopesDefinitionMap = map[string]*Definition{
scopeAliasBasic: &Definition{
ID: "scope_alias_basic",
Priority: priorityBasic,
},
oidc.ScopeOfflineAccess: &Definition{
ID: "scope_offline_access",
Priority: priorityOfflineAccess,
},
}
// Scopes contain collections for scope related meta data
type Scopes struct {
Mapping map[string]string `json:"mapping" yaml:"mapping"`
Definitions map[string]*Definition `json:"definitions" yaml:"scopes"`
}
// NewScopesFromIDs creates a new scopes meta data collection from the provided
// scopes IDs optionally also adding definitions from a parent.
func NewScopesFromIDs(scopes map[string]bool, parent *Scopes) *Scopes {
mapping := make(map[string]string)
definitions := make(map[string]*Definition)
for scope, enabled := range scopes {
if !enabled {
continue
}
alias := scope
if mapped, ok := parent.Mapping[scope]; ok {
alias = mapped
mapping[scope] = mapped
} else if mapped, ok := defaultScopesMap[scope]; ok {
alias = mapped
mapping[scope] = mapped
}
if definition, ok := parent.Definitions[alias]; ok {
definitions[alias] = definition
} else if definition, ok := defaultScopesDefinitionMap[alias]; ok {
definitions[alias] = definition
}
}
return &Scopes{
Mapping: mapping,
Definitions: definitions,
}
}
// NewScopesFromFile loads scope definitions from a file.
func NewScopesFromFile(scopesConfFilepath string, logger logrus.FieldLogger) (*Scopes, error) {
scopes := &Scopes{}
if scopesConfFilepath != "" {
logger.Debugf("parsing scopes conf from %v", scopesConfFilepath)
confFile, err := ioutil.ReadFile(scopesConfFilepath)
if err != nil {
return nil, err
}
err = yaml.Unmarshal(confFile, scopes)
if err != nil {
return nil, err
}
for id, definition := range scopes.Definitions {
fields := logrus.Fields{
"id": id,
"priority": definition.Priority,
}
logger.WithFields(fields).Debugln("registered scope")
}
for id, mapped := range scopes.Mapping {
fields := logrus.Fields{
"id": id,
"to": mapped,
}
logger.WithFields(fields).Debugln("registered scope mapping")
}
}
if scopes.Mapping == nil {
scopes.Mapping = make(map[string]string)
}
if scopes.Definitions == nil {
scopes.Definitions = make(map[string]*Definition)
}
return scopes, nil
}
// Extend adds the provided scope mappings and definitions to the accociated
// scopes mappings and definitions with replacing already existing. If scopes is
// nil, Extends is a no-op.
func (s *Scopes) Extend(scopes *Scopes) error {
if scopes == nil {
return nil
}
for scope, definition := range scopes.Definitions {
s.Definitions[scope] = definition
}
for mapped, mapping := range scopes.Mapping {
s.Mapping[mapped] = mapping
}
return nil
}
+178
View File
@@ -0,0 +1,178 @@
/*
* 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 identifier
import (
"net/url"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/lico/identifier/meta"
"github.com/libregraph/lico/identity/clients"
)
// A LogonRequest is the request data as sent to the logon endpoint
type LogonRequest struct {
State string `json:"state"`
Params []string `json:"params"`
Hello *HelloRequest `json:"hello"`
}
// A LogonResponse holds a response as sent by the logon endpoint.
type LogonResponse struct {
Success bool `json:"success"`
State string `json:"state"`
Hello *HelloResponse `json:"hello"`
}
// A HelloRequest is the request data as send to the hello endpoint.
type HelloRequest struct {
State string `json:"state"`
Flow string `json:"flow"`
RawScope string `json:"scope"`
RawPrompt string `json:"prompt"`
ClientID string `json:"client_id"`
RawRedirectURI string `json:"redirect_uri"`
RawIDTokenHint string `json:"id_token_hint"`
RawMaxAge string `json:"max_age"`
Scopes map[string]bool `json:"-"`
Prompts map[string]bool `json:"-"`
RedirectURI *url.URL `json:"-"`
IDTokenHint *jwt.Token `json:"-"`
MaxAge time.Duration `json:"-"`
//TODO(longsleep): Add support to pass request parameters as JWT as
// specified in http://openid.net/specs/openid-connect-core-1_0.html#JWTRequests
}
func (hr *HelloRequest) parse() error {
hr.Scopes = make(map[string]bool)
hr.Prompts = make(map[string]bool)
hr.RedirectURI, _ = url.Parse(hr.RawRedirectURI)
if hr.RawScope != "" {
for _, scope := range strings.Split(hr.RawScope, " ") {
hr.Scopes[scope] = true
}
}
if hr.RawPrompt != "" {
for _, prompt := range strings.Split(hr.RawPrompt, " ") {
hr.Prompts[prompt] = true
}
}
if hr.RawMaxAge != "" {
maxAgeInt, err := strconv.ParseInt(hr.RawMaxAge, 10, 64)
if err != nil {
return err
}
hr.MaxAge = time.Duration(maxAgeInt) * time.Second
}
return nil
}
// A HelloResponse holds a response as sent by the hello endpoint.
type HelloResponse struct {
State string `json:"state"`
Flow string `json:"flow"`
Success bool `json:"success"`
Username string `json:"username,omitempty"`
DisplayName string `json:"displayName,omitempty"`
Next string `json:"next,omitempty"`
ContinueURI string `json:"continue_uri,omitempty"`
Scopes map[string]bool `json:"scopes,omitempty"`
ClientDetails *clients.Details `json:"client,omitempty"`
Meta *meta.Meta `json:"meta,omitempty"`
Branding *meta.Branding `json:"branding,omitempty"`
}
// A StateRequest is a general request with a state.
type StateRequest struct {
State string
}
// A StateResponse hilds a response as reply to a StateRequest.
type StateResponse struct {
Success bool `json:"success"`
State string `json:"state"`
}
// StateData contains data bound to a state.
type StateData struct {
State string `json:"state"`
Mode string `json:"mode,omitempty"`
RawQuery string `json:"raw_query,omitempty"`
ClientID string `json:"client_id"`
Ref string `json:"ref,omitempty"`
Extra map[string]interface{} `json:"extra,omitempty"`
Trampolin *TrampolinData `json:"trampolin,omitempty"`
}
type TrampolinData struct {
URI string `json:"uri"`
Scope string `json:"scope"`
}
// A ConsentRequest is the request data as sent to the consent endpoint.
type ConsentRequest struct {
State string `json:"state"`
Allow bool `json:"allow"`
RawScope string `json:"scope"`
ClientID string `json:"client_id"`
RawRedirectURI string `json:"redirect_uri"`
Ref string `json:"ref"`
Nonce string `json:"flow_nonce"`
}
// Consent is the data received and sent to allow or cancel consent flows.
type Consent struct {
Allow bool `json:"allow"`
RawScope string `json:"scope"`
}
// Scopes returns the associated consents approved scopes filtered by the
//provided requested scopes and the full unfiltered approved scopes table.
func (c *Consent) Scopes(requestedScopes map[string]bool) (map[string]bool, map[string]bool) {
scopes := make(map[string]bool)
if c.RawScope != "" {
for _, scope := range strings.Split(c.RawScope, " ") {
scopes[scope] = true
}
}
approved := make(map[string]bool)
for n, v := range requestedScopes {
if ok, _ := scopes[n]; ok && v {
approved[n] = true
}
}
return approved, scopes
}
+41
View File
@@ -0,0 +1,41 @@
/*
* 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 identifier
const (
// ModeLogonUsernameEmptyPasswordCookie is the logon mode which requires a
// username which matches the currently signed in user in the cookie and an
// empty password.
ModeLogonUsernameEmptyPasswordCookie = "0"
// ModeLogonUsernamePassword is the logon mode which requires a username
// and a password.
ModeLogonUsernamePassword = "1"
)
const (
// MustBeSignedIn is a authorize mode which tells the authorization code,
// that it is expected to have a signed in user and everything else should
// be treated as error.
MustBeSignedIn = "must"
)
const (
// StateModeEndSession is a state mode which selects end session specific
// actions when processing state requests.
StateModeEndSession = "0"
)
+396
View File
@@ -0,0 +1,396 @@
/*
* 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 identifier
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/oidc-go"
"github.com/longsleep/rndm"
"golang.org/x/oauth2"
"github.com/libregraph/lico/identity/authorities"
konnectoidc "github.com/libregraph/lico/oidc"
"github.com/libregraph/lico/oidc/payload"
"github.com/libregraph/lico/utils"
)
func (i *Identifier) writeOAuth2Start(rw http.ResponseWriter, req *http.Request, authority *authorities.Details) {
var err error
if authority == nil {
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2TemporarilyUnavailable, "no authority")
} else if !authority.IsReady() {
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2TemporarilyUnavailable, "authority not ready")
}
switch typedErr := err.(type) {
case nil:
// breaks
case *konnectoidc.OAuth2Error:
// Redirect back, with error.
i.logger.WithFields(utils.ErrorAsFields(err)).Debugln("oauth2 start error")
// NOTE(longsleep): Pass along error ID but not the description to avoid
// leaking potentially internal information to our RP.
uri, _ := url.Parse(i.authorizationEndpointURI.String())
query, _ := url.ParseQuery(req.URL.RawQuery)
query.Del("flow")
query.Set("error", typedErr.ErrorID)
query.Set("error_description", "identifier failed to authenticate")
uri.RawQuery = query.Encode()
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
return
default:
i.logger.WithError(err).Errorln("identifier failed to process oauth2 start")
i.ErrorPage(rw, http.StatusInternalServerError, "", "oauth2 start failed")
return
}
sd := &StateData{
State: rndm.GenerateRandomString(32),
RawQuery: req.URL.RawQuery,
ClientID: authority.ClientID,
Ref: authority.ID,
}
// Construct URL to redirect client to external OAuth2 authorize endpoints.
uri, extra, err := authority.MakeRedirectAuthenticationRequestURL(sd.State)
if err != nil {
i.logger.WithError(err).Errorln("identifier failed to create authentication request: %w", err)
i.ErrorPage(rw, http.StatusInternalServerError, "", "oauth2 start failed")
return
}
if extra != nil {
sd.Extra = extra
} else {
sd.Extra = make(map[string]interface{})
}
query := uri.Query()
query.Add("client_id", authority.ClientID)
if authority.ResponseType != "" {
query.Add("response_type", authority.ResponseType)
}
if authority.ResponseMode != "" {
query.Add("response_mode", authority.ResponseMode)
}
query.Add("scope", strings.Join(authority.Scopes, " "))
query.Add("redirect_uri", i.oauth2CbEndpointURI.String())
query.Add("nonce", rndm.GenerateRandomString(32))
if authority.CodeChallengeMethod != "" {
codeVerifier := rndm.GenerateRandomString(32)
sd.Extra["code_verifier"] = codeVerifier
codeChallenge := ""
if codeChallenge, err = oidc.MakeCodeChallenge(authority.CodeChallengeMethod, codeVerifier); err == nil {
query.Add("code_challenge", codeChallenge)
query.Add("code_challenge_method", authority.CodeChallengeMethod)
} else {
i.logger.WithError(err).Debugln("identifier failed to create oauth2 code challenge")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to create code challenge")
return
}
}
if display := req.Form.Get("display"); display != "" {
query.Add("display", display)
}
if prompt := req.Form.Get("prompt"); prompt != "" && prompt != oidc.PromptConsent {
// Pass along all prompt values, except consent to external provider and
// handle consent as needed ourselves.
query.Add("prompt", prompt)
}
if maxAge := req.Form.Get("max_age"); maxAge != "" {
query.Add("max_age", maxAge)
}
if uiLocales := req.Form.Get("ui_locales"); uiLocales != "" {
query.Add("ui_locales", uiLocales)
}
if acrValues := req.Form.Get("acr_values"); acrValues != "" {
query.Add("acr_values", acrValues)
}
if claimsLocales := req.Form.Get("claims_locales"); claimsLocales != "" {
query.Add("claims_locales", claimsLocales)
}
// Set cookie which is consumed by the callback later.
err = i.SetStateToStateCookie(req.Context(), rw, "oauth2/cb", sd)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to set oauth2 state cookie")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to set cookie")
return
}
uri.RawQuery = query.Encode()
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
}
func (i *Identifier) writeOAuth2Cb(rw http.ResponseWriter, req *http.Request) {
// Callbacks from authorization or end session. Validate as specified at
// https://tools.ietf.org/html/rfc6749#section-4.1.2 and https://tools.ietf.org/html/rfc6749#section-10.12.
var err error
var sd *StateData
var user *IdentifiedUser
var userInfoClaims jwt.MapClaims
var authority *authorities.Details
for {
sd, err = i.GetStateFromStateCookie(req.Context(), rw, req, "oauth2/cb", req.Form.Get("state"))
if err != nil {
err = fmt.Errorf("failed to decode oauth2 cb state: %w", err)
break
}
if sd == nil {
err = errors.New("state not found")
break
}
// Load authority with client_id in state.
authority, _ = i.authorities.Lookup(req.Context(), sd.Ref)
if authority == nil {
i.logger.WithField("client_id", sd.ClientID).Debugln("identifier failed to find authority in oauth2 cb")
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2InvalidRequest, "unknown client_id")
break
}
if authority.AuthorityType != authorities.AuthorityTypeOIDC {
err = errors.New("unknown authority type")
break
}
// Check incoming state type.
var done bool
done, err = func() (bool, error) {
switch sd.Mode {
case StateModeEndSession:
// Special mode. When in end session, take value from state and
// redirect to it. This completes end session callback.
uri, _ := url.Parse(sd.RawQuery)
if uri == nil {
return false, konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2InvalidRequest, "no uri in state")
}
if sd.State != "" {
query := uri.Query()
query.Set("state", sd.State)
uri.RawQuery = query.Encode()
}
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
return true, nil
default:
// Continue further.
}
return false, nil
}()
if err != nil {
break
}
if done {
// Already done, nothing further so return.
return
}
if authority.ResponseType == oidc.ResponseTypeCode ||
authority.ResponseType == oidc.ResponseTypeCodeIDToken ||
authority.ResponseType == oidc.ResponseTypeCodeIDTokenToken {
// Exchange code for ID token.
md := authority.Metadata().(*oidc.WellKnown)
config := &oauth2.Config{
ClientID: authority.ClientID,
ClientSecret: authority.ClientSecret,
RedirectURL: i.oauth2CbEndpointURI.String(),
Endpoint: oauth2.Endpoint{
TokenURL: md.TokenEndpoint,
},
Scopes: authority.Scopes,
}
var httpClient *http.Client
if authority.Insecure {
httpClient = utils.InsecureHTTPClient
} else {
httpClient = utils.DefaultHTTPClient
}
t, exchangeErr := config.Exchange(
context.WithValue(req.Context(), oauth2.HTTPClient, httpClient),
req.Form.Get("code"),
oauth2.SetAuthURLParam("code_verifier",
sd.Extra["code_verifier"].(string)),
)
if exchangeErr != nil {
err = fmt.Errorf("failed to exchange code for token: %w", exchangeErr)
break
}
// Inject found data into request for later parse.
req.Form.Set("access_token", t.AccessToken)
req.Form.Set("token_type", t.TokenType)
req.Form.Set("refresh_token", t.RefreshToken)
if v, ok := t.Extra("expires_in").(string); ok {
req.Form.Set("expires_in", v)
}
if v, ok := t.Extra("id_token").(string); ok {
req.Form.Set("id_token", v)
}
// Fetch userinfo.
uiReq, requestErr := http.NewRequest(http.MethodGet, md.UserInfoEndpoint, http.NoBody)
if requestErr != nil {
err = fmt.Errorf("failed to create userinfo request: %w", requestErr)
break
}
t.SetAuthHeader(uiReq)
uiResp, responseErr := httpClient.Do(uiReq)
if responseErr != nil {
err = fmt.Errorf("failed to get userinfo: %w", responseErr)
break
}
// Decode userinfo as JSON, directly into the claims set.
if decodeErr := json.NewDecoder(uiResp.Body).Decode(&userInfoClaims); decodeErr != nil {
err = fmt.Errorf("failed to decode userinfo response: %w", decodeErr)
uiResp.Body.Close()
break
}
uiResp.Body.Close()
}
// Parse incoming state response.
var authenticationSuccess *payload.AuthenticationSuccess
if authenticationSuccessRaw, parseErr := authority.ParseStateResponse(req, sd.State, sd.Extra); parseErr == nil {
authenticationSuccess = authenticationSuccessRaw.(*payload.AuthenticationSuccess)
} else {
err = parseErr
break
}
// Parse and validate IDToken.
idToken, idTokenParseErr := jwt.ParseWithClaims(authenticationSuccess.IDToken, userInfoClaims, authority.JWTKeyfunc())
if idTokenParseErr != nil {
if authority.Insecure {
i.logger.WithField("client_id", sd.ClientID).WithError(idTokenParseErr).Warnln("identifier ignoring validation error for insecure authority")
} else {
i.logger.WithError(idTokenParseErr).Debugln("identifier failed to validate oauth2 cb id token")
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2ServerError, "authority response validation failed")
break
}
}
if claims, _ := idToken.Claims.(jwt.MapClaims); claims == nil {
err = errors.New("invalid id token claims")
break
}
// Lookup username and user.
un, extra, claimsErr := authority.IdentityClaimValue(idToken)
if claimsErr != nil {
i.logger.WithError(claimsErr).Debugln("identifier failed to get username from oauth2 cb id token claims")
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2InsufficientScope, "identity claim not found")
break
}
username := &un
// TODO(longsleep): This flow currently does not provide a hello
// context, means that downwards a backend might fail to resolve the
// user when it requires additional information for multiple backend
// routing.
user, err = i.resolveUser(req.Context(), *username)
if err != nil {
i.logger.WithError(err).WithField("username", *username).Debugln("identifier failed to resolve oauth2 cb user with backend")
// TODO(longsleep): Break on validation error.
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2AccessDenied, "failed to resolve user")
break
}
if user == nil || user.Subject() == "" {
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2AccessDenied, "no such user")
break
}
var logonRef string
if rawIDToken, ok := extra["RawIDToken"]; ok {
logonRef = rawIDToken.(string)
}
if logonRef != "" {
user.logonRef = &logonRef
}
// Get user meta data.
// TODO(longsleep): This is an additional request to the backend. This
// should be avoided. Best would be if the backend would return everything
// in one shot (TODO in core).
err = i.updateUser(req.Context(), user, authority)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to update user data in oauth2 cb request")
}
// Set logon time.
user.logonAt = time.Now()
err = i.SetUserToLogonCookie(req.Context(), rw, user)
if err != nil {
i.logger.WithError(err).Errorln("identifier failed to serialize logon ticket in oauth2 cb")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to serialize logon ticket")
return
}
break
}
if sd == nil {
i.logger.WithError(err).Debugln("identifier oauth2 cb without state")
i.ErrorPage(rw, http.StatusBadRequest, "", "state not found")
return
}
uri, _ := url.Parse(i.authorizationEndpointURI.String())
query, _ := url.ParseQuery(sd.RawQuery)
query.Del("flow")
query.Set("identifier", MustBeSignedIn)
if query.Get("prompt") == oidc.PromptSelectAccount {
// Remove select_acount prompt for our secondary indentifier, it was
// already processed by the external provider.
query.Del("prompt")
}
switch typedErr := err.(type) {
case nil:
// breaks
case *konnectoidc.OAuth2Error:
// Pass along OAuth2 error.
i.logger.WithFields(utils.ErrorAsFields(err)).Debugln("oauth2 cb error")
// NOTE(longsleep): Pass along error ID but not the description to avoid
// leaking potetially internal information to our RP.
query.Set("error", typedErr.ErrorID)
query.Set("error_description", "identifier failed to authenticate")
//breaks
default:
i.logger.WithError(err).Errorln("identifier failed to process oauth2 cb")
i.ErrorPage(rw, http.StatusInternalServerError, "", "oauth2 cb failed")
return
}
uri.RawQuery = query.Encode()
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
}
+121
View File
@@ -0,0 +1,121 @@
{
"name": "identifier",
"version": "1.0.0",
"private": true,
"homepage": ".",
"dependencies": {
"@fontsource/roboto": "^4.5.8",
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.11.3",
"axios": "^0.22.0",
"classnames": "^2.3.2",
"glob": "^8.1.0",
"i18next": "^21.10.0",
"i18next-browser-languagedetector": "^6.1.8",
"i18next-http-backend": "^1.4.5",
"i18next-resources-to-backend": "^1.0.0",
"query-string": "^7.1.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-i18next": "^11.18.6",
"react-redux": "^7.2.9",
"react-router": "^5.3.4",
"react-router-dom": "5.3.4",
"redux": "^4.2.1",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.4.2",
"render-if": "^0.1.1",
"validator": "^13.12.0",
"web-vitals": "^1.1.2"
},
"scripts": {
"start": "vite",
"build": "vite build",
"serve": "vite preview",
"test": "vitest",
"lint": "eslint --max-warnings=0 src/**/*.{ts,tsx,js,jsx}",
"licenses": "node ../scripts/js-license-ranger.mjs",
"analyze": "source-map-explorer 'build/static/assets/*.js'"
},
"devDependencies": {
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^12.1.5",
"@testing-library/user-event": "^12.8.3",
"@types/jest": "^29.5.8",
"@types/node": "^20.9.0",
"@types/react": "^17.0.70",
"@types/react-dom": "^17.0.23",
"@types/react-redux": "^7.1.25",
"@types/redux-logger": "^3.0.12",
"@types/validator": "^13",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.9.0",
"@typescript-eslint/typescript-estree": "^6.11.0",
"@vitejs/plugin-legacy": "^4.0.0",
"@vitejs/plugin-react": "^4.1.1",
"cldr": "^7.4.0",
"eslint": "^8.53.0",
"eslint-config-react-app-bump": "^1.0.16",
"eslint-plugin-i18next": "^5.2.1",
"i18next-conv": "^12.1.1",
"i18next-parser": "^5.4.0",
"if-node-version": "^1.1.1",
"jsdom": "^22.1.0",
"source-map-explorer": "^2.5.3",
"terser": "^5.30.4",
"typescript": "^5.2.2",
"vite": "^4.5.13",
"vite-plugin-checker": "^0.6.2",
"vite-plugin-eslint": "^1.8.1",
"vitest": "^0.34.6"
},
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}"
]
},
"eslintConfig": {
"plugins": [
"i18next"
],
"extends": [
"react-app-bump",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:i18next/recommended"
],
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error"
],
"no-use-before-define": "off",
"@typescript-eslint/no-use-before-define": [
"error"
],
"i18next/no-literal-string": [
"off",
{
"markupOnly": true
}
],
"react/prop-types": [
"warn"
]
}
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"packageManager": "yarn@4.0.2"
}
+464
View File
@@ -0,0 +1,464 @@
/*
* 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 identifier
import (
"errors"
"fmt"
"net/http"
"net/url"
"time"
"github.com/crewjam/saml"
"github.com/libregraph/oidc-go"
"github.com/longsleep/rndm"
"github.com/sirupsen/logrus"
"github.com/libregraph/lico/identity/authorities"
konnectoidc "github.com/libregraph/lico/oidc"
"github.com/libregraph/lico/identity/authorities/samlext"
"github.com/libregraph/lico/utils"
)
func (i *Identifier) writeSAML2Start(rw http.ResponseWriter, req *http.Request, authority *authorities.Details) {
var err error
if authority == nil {
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2TemporarilyUnavailable, "no authority")
} else if !authority.IsReady() {
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2TemporarilyUnavailable, "authority not ready")
}
switch typedErr := err.(type) {
case nil:
// breaks
case *konnectoidc.OAuth2Error:
// Redirect back, with error.
i.logger.WithFields(utils.ErrorAsFields(err)).Debugln("saml2 start error")
// NOTE(longsleep): Pass along error ID but not the description to avoid
// leaking potentially internal information to our RP.
uri, _ := url.Parse(i.authorizationEndpointURI.String())
query, _ := url.ParseQuery(req.URL.RawQuery)
query.Del("flow")
query.Set("error", typedErr.ErrorID)
query.Set("error_description", "identifier failed to authenticate")
uri.RawQuery = query.Encode()
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
return
default:
i.logger.WithError(err).Errorln("identifier failed to process saml2 start")
i.ErrorPage(rw, http.StatusInternalServerError, "", "saml2 start failed")
return
}
sd := &StateData{
State: rndm.GenerateRandomString(32),
RawQuery: req.URL.RawQuery,
Ref: authority.ID,
}
uri, extra, err := authority.MakeRedirectAuthenticationRequestURL(sd.State)
if err != nil {
i.logger.WithError(err).Errorln("identifier failed to create authentication request: %w", err)
i.ErrorPage(rw, http.StatusInternalServerError, "", "saml2 start failed")
return
}
sd.Extra = extra
// Set cookie which is consumed by the callback later.
err = i.SetStateToStateCookie(req.Context(), rw, "saml2/acs", sd)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to set saml2 state cookie")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to set cookie")
return
}
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
}
func (i *Identifier) writeSAML2AssertionConsumerService(rw http.ResponseWriter, req *http.Request) {
var err error
var sd *StateData
var user *IdentifiedUser
var authority *authorities.Details
for {
sd, err = i.GetStateFromStateCookie(req.Context(), rw, req, "saml2/acs", req.Form.Get("RelayState"))
if err != nil {
err = fmt.Errorf("failed to decode saml2 acs state: %v", err)
break
}
if sd == nil {
err = errors.New("state not found")
break
}
// Load authority with client_id in state.
authority, _ = i.authorities.Lookup(req.Context(), sd.Ref)
if authority == nil {
i.logger.Debugln("identifier failed to find authority in saml2 acs")
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2InvalidRequest, "unknown client_id")
break
}
if authority.AuthorityType != authorities.AuthorityTypeSAML2 {
err = errors.New("unknown authority type")
break
}
// Parse incoming state response.
var assertion *saml.Assertion
if assertionRaw, parseErr := authority.ParseStateResponse(req, sd.State, sd.Extra); parseErr == nil {
assertion = assertionRaw.(*saml.Assertion)
} else {
err = parseErr
break
}
// Lookup username and user.
un, claims, claimsErr := authority.IdentityClaimValue(assertion)
if claimsErr != nil {
i.logger.WithError(claimsErr).Debugln("identifier failed to get username from saml2 acs assertion")
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2InsufficientScope, "identity claim not found")
break
}
username := &un
// TODO(longsleep): This flow currently does not provide a hello
// context, means that downwards a backend might fail to resolve the
// user when it requires additional information for multiple backend
// routing.
user, err = i.resolveUser(req.Context(), *username)
if err != nil {
i.logger.WithError(err).WithField("username", *username).Debugln("identifier failed to resolve saml2 acs user with backend")
// TODO(longsleep): Break on validation error.
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2AccessDenied, "failed to resolve user")
break
}
if user == nil || user.Subject() == "" {
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2AccessDenied, "no such user")
break
}
// Apply additional authority claims.
if sessionNotOnOrAfter, ok := claims["SessionNotOnOrAfter"]; ok {
user.expiresAfter = sessionNotOnOrAfter.(*time.Time)
}
var logonRef string
if nameIDTransient, ok := claims["TransientNameID"]; ok {
logonRef = "transient:" + nameIDTransient.(string)
} else if nameIDPersistent, ok := claims["PersistentNameID"]; ok {
logonRef = "persistent:" + nameIDPersistent.(string)
} else if nameIDUnspecified, ok := claims["UnspecifiedNameID"]; ok {
logonRef = "unspecified:" + nameIDUnspecified.(string)
}
if logonRef != "" {
user.logonRef = &logonRef
}
if authority.Trusted {
// Use external authority session, if the external authority is trusted.
if sessionIndexString, ok := claims["SessionIndex"]; ok {
sessionIndex := sessionIndexString.(string)
user.sessionRef = &sessionIndex
}
}
// Get user meta data.
// TODO(longsleep): This is an additional request to the backend. This
// should be avoided. Best would be if the backend would return everything
// in one shot (TODO in core).
err = i.updateUser(req.Context(), user, authority)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to get user data in saml2 acs request")
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2AccessDenied, "failed to get user data")
break
}
// Set logon time.
user.logonAt = time.Now()
err = i.SetUserToLogonCookie(req.Context(), rw, user)
if err != nil {
i.logger.WithError(err).Errorln("identifier failed to serialize logon ticket in saml2 acs")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to serialize logon ticket")
return
}
break
}
if sd == nil {
i.logger.WithError(err).Debugln("identifier saml2 acs without state")
i.ErrorPage(rw, http.StatusBadRequest, "", "state not found")
return
}
uri, _ := url.Parse(i.authorizationEndpointURI.String())
query, _ := url.ParseQuery(sd.RawQuery)
query.Del("flow")
query.Set("identifier", MustBeSignedIn)
query.Set("prompt", oidc.PromptNone)
switch typedErr := err.(type) {
case nil:
// breaks
case *saml.InvalidResponseError:
i.logger.WithError(err).WithFields(logrus.Fields{
"reason": typedErr.PrivateErr,
}).Debugf("saml2 acs invalid response")
query.Set("error", oidc.ErrorCodeOAuth2AccessDenied)
query.Set("error_description", "identifier received invalid response")
// breaks
case *konnectoidc.OAuth2Error:
// Pass along OAuth2 error.
i.logger.WithFields(utils.ErrorAsFields(err)).Debugln("saml2 acs error")
// NOTE(longsleep): Pass along error ID but not the description to avoid
// leaking potetially internal information to our RP.
query.Set("error", typedErr.ErrorID)
query.Set("error_description", "identifier failed to authenticate")
//breaks
default:
i.logger.WithError(err).Errorln("identifier failed to process saml2 acs")
i.ErrorPage(rw, http.StatusInternalServerError, "", "saml2 acs failed")
return
}
uri.RawQuery = query.Encode()
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
}
func (i *Identifier) writeSAMLSingleLogoutServiceRequest(rw http.ResponseWriter, req *http.Request) {
lor, err := samlext.NewIdpLogoutRequest(req)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to process saml2 slo request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to parse request")
return
}
err = lor.Validate()
if err != nil {
i.logger.WithError(err).Debugln("identifier saml2 slo request validation failed")
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request validation failed")
return
}
// In http://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf §3.4.5.2
// we get a description of the Destination attribute:
//
// If the message is signed, the Destination XML attribute in the root SAML
// element of the protocol message MUST contain the URL to which the sender
// has instructed the user agent to deliver the message. The recipient MUST
// then verify that the value matches the location at which the message has
// been received.
//
// We require the destination be correct either (a) if signing is enabled or
// (b) if it was provided.
mustHaveDestination := lor.SigAlg != nil
mustHaveDestination = mustHaveDestination || lor.Request.Destination != ""
if mustHaveDestination {
uri, _ := i.absoluteURLForRoute("saml2/slo")
if lor.Request.Destination != uri.String() {
i.logger.WithField("destination", lor.Request.Destination).Debugln("identifier saml2 slo request with wrong desitation")
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request destination wrong")
return
}
}
// Find matching authority.
authority, found := i.authorities.Find(req.Context(), func(authority authorities.AuthorityRegistration) bool {
if authority.AuthorityType() != authorities.AuthorityTypeSAML2 {
return false
}
if lor.Request.Issuer.Value == authority.Issuer() {
return true
}
return false
})
if !found {
i.logger.WithField("issuer", lor.Request.Issuer.Value).Debugln("identifier saml2 slo request from unknown issuer")
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request issuer unknown")
return
}
authorityDetails := authority.Authority()
if lor.SigAlg == nil {
// Never consider trusted if not signed.
authorityDetails.Trusted = false
}
if authorityDetails.AuthorityType != authorities.AuthorityTypeSAML2 {
i.logger.WithField("issuer", lor.Request.Issuer.Value).Debugln("identifier saml2 slo request for unknown authority type")
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request issuer authority type unknown")
return
}
// Validate.
validated, err := authority.ValidateIdpEndSessionRequest(lor, lor.RelayState)
if err != nil {
i.logger.WithError(err).WithField("issuer", authority.Issuer()).Debugln("identifier saml2 slo request authority validation failed")
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request authority validation failed")
return
}
if !validated && authorityDetails.Trusted {
// Never consider unvalidated logout requests as trusted.
authorityDetails.Trusted = false
}
user, _ := i.GetUserFromLogonCookie(req.Context(), req, 0, false)
if user != nil {
// Compare signed in SAML SessionIndex with the on provided in the LogoutRequest.
if user.SessionRef() != nil {
if lor.Request.SessionIndex == nil {
i.logger.Debugln("identifier saml2 slo request without session index")
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request missing session index")
return
}
if lor.Request.SessionIndex.Value != *user.SessionRef() {
i.logger.Debugln("identifier saml2 slo request for other session index")
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request session index mismatch")
return
}
}
if authorityDetails != nil && authorityDetails.Trusted {
// Directly clear identifier session when a trusted authority requests it.
err = i.UnsetLogonCookie(req.Context(), user, rw)
if err != nil {
i.logger.WithError(err).Errorln("identifier saml2 slo failed to unset logon cookie")
i.ErrorPage(rw, http.StatusInternalServerError, "", "saml2 slo logout failed")
return
}
}
} else {
// Ignore when not signed in, for end session.
}
if authorityDetails == nil || !authorityDetails.Trusted {
// Handle directly by redirecting to our logout confirm url for untrusted
// registies or when no URL was set.
uri, _ := i.absoluteURLForRoute("goodbye")
query := &url.Values{}
uri.RawQuery = query.Encode()
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
return
}
uri, _, err := authorityDetails.MakeRedirectEndSessionResponseURL(lor.Request, lor.RelayState)
if err != nil {
i.logger.WithError(err).Errorln("failed to make saml2 slo redirect request url")
i.ErrorPage(rw, http.StatusInternalServerError, "", "saml2 slo failed")
return
}
if uri == nil {
i.logger.Warnln("saml2 slo reached dead end, no post logout redirect uri available")
// Fall back to logout confirm url.
uri, _ = i.absoluteURLForRoute("goodbye")
}
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
}
func (i *Identifier) writeSAMLSingleLogoutServiceResponse(rw http.ResponseWriter, req *http.Request) {
lor, err := samlext.NewIdpLogoutResponse(req)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to process saml2 slo response")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to parse response")
return
}
err = lor.Validate()
if err != nil {
i.logger.WithError(err).Debugln("identifier saml2 slo response validation failed")
i.ErrorPage(rw, http.StatusBadRequest, "", "response validation failed")
return
}
sd, err := i.GetStateFromStateCookie(req.Context(), rw, req, "_/saml2/slo", lor.RelayState)
if err != nil {
i.logger.WithError(err).Debugln("identifier saml2 slo response failed to load state")
i.ErrorPage(rw, http.StatusBadRequest, "", "response state invalid")
return
}
if sd == nil {
i.logger.WithError(err).Debugln("identifier saml2 slo response failed as state is missing")
i.ErrorPage(rw, http.StatusBadRequest, "", "response state missing")
return
}
authority, found := i.authorities.Get(req.Context(), sd.Ref)
if !found {
i.ErrorPage(rw, http.StatusBadRequest, "", "no authority")
return
}
authorityDetails := authority.Authority()
if lor.SigAlg == nil {
// Never consider trusted if not signed.
authorityDetails.Trusted = false
}
if authorityDetails.AuthorityType != authorities.AuthorityTypeSAML2 {
i.logger.WithField("issuer", authority.Issuer()).Debugln("identifier saml2 slo response for unknown authority type")
i.ErrorPage(rw, http.StatusBadRequest, "", "slo response issuer authority type unknown")
return
}
// Validate.
validated, err := authority.ValidateIdpEndSessionResponse(lor, lor.RelayState)
if err != nil {
i.logger.WithError(err).WithField("issuer", authority.Issuer()).Debugln("identifier saml2 slo response authority validation failed")
i.ErrorPage(rw, http.StatusBadRequest, "", "slo response authority validation failed")
return
}
if !validated && authorityDetails.Trusted {
// Never consider unvalidated logout responses as trusted.
authorityDetails.Trusted = false
}
if lor.Response.Status.StatusCode.Value != saml.StatusSuccess {
i.logger.WithField("status", lor.Response.Status.StatusCode).Debugln("saml2 slo response without success status")
}
// Extract destination URI from state data (its put into the RawQuery field).
uri, err := url.Parse(sd.RawQuery)
if err != nil {
i.logger.WithError(err).Errorln("failed to parse slo response redirect url from state data")
i.ErrorPage(rw, http.StatusInternalServerError, "", "saml2 slo response failed")
return
}
if uri == nil || uri.String() == "" {
i.logger.Warnln("saml2 slo reached dead end, no post logout redirect uri available")
// Fall back to our signed out url or goodbye route.
if i.Config.SignedOutEndpointURI != nil {
uri = i.Config.SignedOutEndpointURI
} else {
uri, _ = i.absoluteURLForRoute("goodbye")
}
}
if sd.State != "" {
query := uri.Query()
query.Set("state", sd.State)
uri.RawQuery = query.Encode()
}
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
}
+73
View File
@@ -0,0 +1,73 @@
/*
* 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 identifier
import (
"html/template"
"net/http"
"net/url"
"github.com/libregraph/lico/version"
)
var trampolinTemplate = template.Must(template.New("trampolin").Parse(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body trampolin="{{.URI}}">
<script src="trampolin/trampolin.js?v={{.Version}}"></script>
<noscript>Javascript is required for this app.</noscript>
</body>
</html>
`))
var trampolinScript = []byte(`(function(window) {
window.location.replace(document.body.getAttribute('trampolin'));
}(window));
`)
var trampolinVersion = url.QueryEscape(version.Version)
type trampolinData struct {
URI string
Version string
}
func (i *Identifier) writeTrampolinHTML(rw http.ResponseWriter, req *http.Request, uri *url.URL) {
data := &trampolinData{
URI: uri.String(),
Version: trampolinVersion,
}
rw.Header().Set("Content-Type", "text/html")
rw.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
rw.Header().Set("Pragma", "no-cache")
rw.Header().Set("Expires", "0")
err := trampolinTemplate.Execute(rw, data)
if err != nil {
i.logger.WithError(err).Errorln("failed to write trampolin")
}
}
func (i *Identifier) writeTrampolinScript(rw http.ResponseWriter, req *http.Request) {
rw.Header().Set("Content-Type", "application/javascript")
rw.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
rw.Write(trampolinScript)
}
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": [
"src/vite-env.d.ts",
"vite.config.ts"
]
}
+251
View File
@@ -0,0 +1,251 @@
/*
* 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 identifier
import (
"context"
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
konnect "github.com/libregraph/lico"
"github.com/libregraph/lico/identifier/backends"
"github.com/libregraph/lico/identity"
"github.com/libregraph/lico/identity/authorities"
)
// A IdentifiedUser is a user with meta data.
type IdentifiedUser struct {
sub string
backend backends.Backend
externalAuthority *authorities.Details
username string
email string
emailVerified bool
displayName string
familyName string
givenName string
id int64
uid string
sessionRef *string
logonRef *string
claims map[string]interface{}
scopes []string
logonAt time.Time
expiresAfter *time.Time
lockedScopes []string
}
// Subject returns the associated users subject field. The subject is the main
// authentication identifier of the user.
func (u *IdentifiedUser) Subject() string {
return u.sub
}
// Email returns the associated users email field.
func (u *IdentifiedUser) Email() string {
return u.email
}
// EmailVerified returns trye if the associated users email field was verified.
func (u *IdentifiedUser) EmailVerified() bool {
return u.emailVerified
}
// Name returns the associated users name field. This is the display name of
// the accociated user.
func (u *IdentifiedUser) Name() string {
return u.displayName
}
// FamilyName returns the associated users family name field.
func (u *IdentifiedUser) FamilyName() string {
return u.familyName
}
// GivenName returns the associated users given name field.
func (u *IdentifiedUser) GivenName() string {
return u.givenName
}
// ID returns the associated users numeric user id. If it is 0, it means that
// this user does not have a numeric ID. Do not use this field to identify a
// user - always use the subject instead. The numeric ID is kept for compatibility
// with systems which require user identification to be numeric.
func (u *IdentifiedUser) ID() int64 {
return u.id
}
// UniqueID returns the accociated users unique user id. When empty, then this
// user does not have a unique ID. This field can be used for unique user mapping
// to external systems which use the same authentication source as Konnect. The
// value depends entirely on the identifier backend.
func (u *IdentifiedUser) UniqueID() string {
return u.uid
}
// Username returns the accociated users username. This might be different or
// the same as the subject, depending on the backend in use. If can also be
// empty, which means that the accociated user does not have a username.
func (u *IdentifiedUser) Username() string {
return u.username
}
// Claims returns extra claims of the accociated user.
func (u *IdentifiedUser) Claims() jwt.MapClaims {
claims := make(map[string]interface{})
claims[konnect.IdentifiedUsernameClaim] = u.Username()
claims[konnect.IdentifiedDisplayNameClaim] = u.Name()
for k, v := range u.claims {
claims[k] = v
}
return jwt.MapClaims(claims)
}
// ScopedClaims returns scope bound extra claims of the accociated user.
func (u *IdentifiedUser) ScopedClaims(authorizedScopes map[string]bool) jwt.MapClaims {
if u.backend == nil {
return nil
}
claims := u.backend.UserClaims(u.Subject(), authorizedScopes)
return jwt.MapClaims(claims)
}
// Scopes returns the scopes attached to this user.
func (u *IdentifiedUser) Scopes() []string {
return u.scopes
}
// LoggedOn returns true if the accociated user has a logonAt time set.
func (u *IdentifiedUser) LoggedOn() (bool, time.Time) {
return !u.logonAt.IsZero(), u.logonAt
}
// SessionRef returns the accociated users underlaying session reference.
func (u *IdentifiedUser) SessionRef() *string {
return u.sessionRef
}
// UserRef returns the accociated users underlaying logon reference.
func (u *IdentifiedUser) LogonRef() *string {
return u.logonRef
}
func (u *IdentifiedUser) ExternalAuthorityID() *string {
if u.externalAuthority == nil {
return nil
}
id := u.externalAuthority.ID
return &id
}
// BackendName returns the accociated users underlaying backend name.
func (u *IdentifiedUser) BackendName() string {
return u.backend.Name()
}
func (u *IdentifiedUser) LockedScopes() []string {
return u.lockedScopes
}
func (i *Identifier) logonUser(ctx context.Context, audience, username, password string) (*IdentifiedUser, error) {
success, subject, sessionRef, u, err := i.backend.Logon(ctx, audience, username, password)
if err != nil {
return nil, err
}
if !success || u == nil {
return nil, nil
}
user := &IdentifiedUser{
sub: *subject,
username: u.Username(),
backend: i.backend,
sessionRef: sessionRef,
claims: u.BackendClaims(),
lockedScopes: u.RequiredScopes(),
}
return user, nil
}
func (i *Identifier) resolveUser(ctx context.Context, username string) (*IdentifiedUser, error) {
u, err := i.backend.ResolveUserByUsername(ctx, username)
if err != nil {
return nil, err
}
if u == nil {
return nil, nil
}
// Construct user from resolved result.
user := &IdentifiedUser{
sub: u.Subject(),
username: u.Username(),
backend: i.backend,
claims: u.BackendClaims(),
lockedScopes: u.RequiredScopes(),
}
return user, nil
}
func (i *Identifier) updateUser(ctx context.Context, user *IdentifiedUser, externalAuthority *authorities.Details) error {
var userID string
identityClaims := user.Claims()
if userIDString, ok := identityClaims[konnect.IdentifiedUserIDClaim]; ok {
userID = userIDString.(string)
}
if userID == "" {
return errors.New("no id claim in user identity claims")
}
u, err := i.backend.GetUser(ctx, userID, user.sessionRef, nil)
if err != nil {
return err
}
if uwp, ok := u.(identity.UserWithProfile); ok {
user.displayName = uwp.Name()
}
user.backend = i.backend
user.externalAuthority = externalAuthority
return nil
}
+55
View File
@@ -0,0 +1,55 @@
/*
* 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 identifier
import (
"encoding/base64"
"fmt"
"net/http"
"strings"
"time"
"github.com/gabriel-vasile/mimetype"
)
var (
farPastExpiryTime = time.Unix(0, 0)
farPastExpiryTimeHTTPHeaderString = farPastExpiryTime.UTC().Format(http.TimeFormat)
)
func addCommonResponseHeaders(header http.Header) {
header.Set("X-Frame-Options", "DENY")
header.Set("X-XSS-Protection", "1; mode=block")
header.Set("X-Content-Type-Options", "nosniff")
header.Set("Referrer-Policy", "origin")
}
func addNoCacheResponseHeaders(header http.Header) {
header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
header.Set("Pragma", "no-cache")
header.Set("Expires", farPastExpiryTimeHTTPHeaderString)
}
func encodeImageAsDataURL(b []byte) (string, error) {
mt := mimetype.Detect(b)
if !strings.HasPrefix(mt.String(), "image/") {
return "", fmt.Errorf("not an image: %s", mt)
}
return "data:" + mt.String() + ";base64," + base64.StdEncoding.EncodeToString(b), nil
}
+70
View File
@@ -0,0 +1,70 @@
import { defineConfig, splitVendorChunkPlugin } from "vite";
import react from "@vitejs/plugin-react";
import checker from "vite-plugin-checker";
import legacy from "@vitejs/plugin-legacy";
const addScriptCSPNoncePlaceholderPlugin = () => {
return {
name: "add-script-nonce-placeholderP-plugin",
apply: "build",
transformIndexHtml: {
order: "post",
handler(htmlData) {
return htmlData.replaceAll(
/<script nomodule>/gi,
`<script nomodule nonce="__CSP_NONCE__">`
).replaceAll(
/<script type="module">/gi,
`<script type="module" nonce="__CSP_NONCE__">`
).replaceAll(
/<script nomodule crossorigin id="vite-legacy-entry"/gi,
`<script nomodule crossorigin id="vite-legacy-entry" nonce="__CSP_NONCE__"`
);
},
},
};
};
export default defineConfig((env) => {
return {
build: {
outDir: 'build',
assetsDir: 'static/assets',
manifest: 'asset-manifest.json',
sourcemap: true,
},
base: './',
server: {
port: 3001,
strictPort: true,
host: '127.0.0.1',
hmr: {
protocol: 'ws',
host: '127.0.0.1',
clientPort: 3001,
},
},
plugins: [
react(),
legacy({
targets: ['edge 18'],
}),
env.mode !== "test" &&
checker({
typescript: true,
eslint: {
lintCommand: 'eslint --max-warnings=0 src',
},
}),
splitVendorChunkPlugin(),
addScriptCSPNoncePlaceholderPlugin(),
],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './tests/setup.js',
},
};
});