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
+58
View File
@@ -0,0 +1,58 @@
package assets
import (
"net/http"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/x/io/fsx"
"github.com/qsfera/server/services/idp"
"github.com/qsfera/server/services/idp/pkg/config"
)
// New returns a new http filesystem to serve assets.
func New(opts ...Option) http.FileSystem {
options := newOptions(opts...)
var assetFS fsx.FS = fsx.NewBasePathFs(fsx.FromIOFS(idp.Assets), "assets")
// only use a fsx.NewFallbackFS and fsx.OsFs if a path is set, use the embedded fs only otherwise
if options.Config.Asset.Path != "" {
assetFS = fsx.NewFallbackFS(fsx.NewBasePathFs(fsx.NewOsFs(), options.Config.Asset.Path), assetFS)
}
return http.FS(assetFS.IOFS())
}
// Option defines a single option function.
type Option func(o *Options)
// Options define the available options for this package.
type Options struct {
Logger log.Logger
Config *config.Config
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
@@ -0,0 +1,136 @@
/*
* 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 bootstrap
import (
"fmt"
"os"
"github.com/libregraph/lico/bootstrap"
"github.com/libregraph/lico/identifier"
"github.com/libregraph/lico/identity"
"github.com/libregraph/lico/identity/managers"
cs3 "github.com/qsfera/server/services/idp/pkg/backends/cs3/identifier"
)
// Identity managers.
const (
identityManagerName = "cs3"
)
// Register adds the CS3 identity manager to the lico bootstrap
func Register() error {
return bootstrap.RegisterIdentityManager(identityManagerName, NewIdentityManager)
}
// MustRegister adds the CS3 identity manager to the lico bootstrap or panics
func MustRegister() {
if err := Register(); err != nil {
panic(err)
}
}
// NewIdentityManager produces a CS3 backed identity manager instance for the idp
func NewIdentityManager(bs bootstrap.Bootstrap) (identity.Manager, error) {
config := bs.Config()
logger := config.Config.Logger
if config.AuthorizationEndpointURI.String() != "" {
return nil, fmt.Errorf("cs3 backend is incompatible with authorization-endpoint-uri parameter")
}
config.AuthorizationEndpointURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/identifier/_/authorize")
if config.EndSessionEndpointURI.String() != "" {
return nil, fmt.Errorf("cs3 backend is incompatible with endsession-endpoint-uri parameter")
}
config.EndSessionEndpointURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/identifier/_/endsession")
if config.SignInFormURI.EscapedPath() == "" {
config.SignInFormURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/identifier")
}
if config.SignedOutURI.EscapedPath() == "" {
config.SignedOutURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/goodbye")
}
identifierBackend, identifierErr := cs3.NewCS3Backend(
config.Config,
config.TLSClientConfig,
// FIXME add a map[string]interface{} property to the lico config.Config so backends can pass custom config parameters through the bootstrap process
os.Getenv("CS3_GATEWAY"),
os.Getenv("CS3_MACHINE_AUTH_API_KEY"),
config.Settings.Insecure,
)
if identifierErr != nil {
return nil, fmt.Errorf("failed to create identifier backend: %v", identifierErr)
}
fullAuthorizationEndpointURL := bootstrap.WithSchemeAndHost(config.AuthorizationEndpointURI, config.IssuerIdentifierURI)
fullSignInFormURL := bootstrap.WithSchemeAndHost(config.SignInFormURI, config.IssuerIdentifierURI)
fullSignedOutEndpointURL := bootstrap.WithSchemeAndHost(config.SignedOutURI, config.IssuerIdentifierURI)
activeIdentifier, err := identifier.NewIdentifier(&identifier.Config{
Config: config.Config,
BaseURI: config.IssuerIdentifierURI,
PathPrefix: bs.MakeURIPath(bootstrap.APITypeSignin, ""),
StaticFolder: config.IdentifierClientPath,
ScopesConf: config.IdentifierScopesConf,
WebAppDisabled: config.IdentifierClientDisabled,
LogonCookieName: "__Secure-KKT", // Kopano-Konnect-Token
LogonCookieSameSite: config.CookieSameSite,
ConsentCookieSameSite: config.CookieSameSite,
StateCookieSameSite: config.CookieSameSite,
AuthorizationEndpointURI: fullAuthorizationEndpointURL,
SignedOutEndpointURI: fullSignedOutEndpointURL,
DefaultBannerLogo: config.IdentifierDefaultBannerLogo,
DefaultSignInPageText: config.IdentifierDefaultSignInPageText,
DefaultUsernameHintText: config.IdentifierDefaultUsernameHintText,
UILocales: config.IdentifierUILocales,
Backend: identifierBackend,
})
if err != nil {
return nil, fmt.Errorf("failed to create identifier: %v", err)
}
err = activeIdentifier.SetKey(config.EncryptionSecret)
if err != nil {
return nil, fmt.Errorf("invalid --encryption-secret parameter value for identifier: %v", err)
}
identityManagerConfig := &identity.Config{
SignInFormURI: fullSignInFormURL,
SignedOutURI: fullSignedOutEndpointURL,
Logger: logger,
ScopesSupported: config.Config.AllowedScopes,
}
identifierIdentityManager := managers.NewIdentifierIdentityManager(identityManagerConfig, activeIdentifier)
logger.Infoln("using identifier backed identity manager")
return identifierIdentityManager, nil
}
@@ -0,0 +1,230 @@
package cs3
import (
"context"
"crypto/tls"
"fmt"
cs3gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/libregraph/lico"
"github.com/libregraph/lico/config"
"github.com/libregraph/lico/identifier/backends"
"github.com/libregraph/lico/identifier/meta/scopes"
"github.com/libregraph/lico/identity"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cmap "github.com/orcaman/concurrent-map"
"github.com/sirupsen/logrus"
)
const cs3BackendName = "identifier-cs3"
var cs3SpportedScopes = []string{
"profile",
"email",
lico.ScopeUniqueUserID,
lico.ScopeRawSubject,
}
// CS3Backend holds the data for the CS3 identifier backend
type CS3Backend struct {
supportedScopes []string
logger logrus.FieldLogger
tlsConfig *tls.Config
gatewayAddr string
machineAuthAPIKey string
insecure bool
sessions cmap.ConcurrentMap
}
// NewCS3Backend creates a new CS3 backend identifier backend
func NewCS3Backend(
c *config.Config,
tlsConfig *tls.Config,
gatewayAddr string,
machineAuthAPIKey string,
insecure bool,
) (*CS3Backend, error) {
// Build supported scopes based on default scopes.
supportedScopes := make([]string, len(cs3SpportedScopes))
copy(supportedScopes, cs3SpportedScopes)
b := &CS3Backend{
supportedScopes: supportedScopes,
logger: c.Logger,
tlsConfig: tlsConfig,
gatewayAddr: gatewayAddr,
machineAuthAPIKey: machineAuthAPIKey,
insecure: insecure,
sessions: cmap.New(),
}
b.logger.Infoln("cs3 backend connection set up")
return b, nil
}
// RunWithContext implements the Backend interface.
func (b *CS3Backend) RunWithContext(_ 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 *CS3Backend) Logon(ctx context.Context, audience, username, password string) (bool, *string, *string, backends.UserFromBackend, error) {
client, err := pool.GetGatewayServiceClient(b.gatewayAddr)
if err != nil {
return false, nil, nil, nil, err
}
res, err := client.Authenticate(ctx, &cs3gateway.AuthenticateRequest{
Type: "basic",
ClientId: username,
ClientSecret: password,
})
if err != nil {
return false, nil, nil, nil, fmt.Errorf("cs3 backend basic authenticate rpc error: %v", err)
}
if res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
return false, nil, nil, nil, fmt.Errorf("cs3 backend basic authenticate failed with code %s: %s", res.GetStatus().GetCode().String(), res.GetStatus().GetMessage())
}
session := createSession(res.GetUser())
user, err := newCS3User(res.GetUser())
if err != nil {
return false, nil, nil, nil, fmt.Errorf("cs3 backend resolve entry data error: %v", err)
}
// Use the users subject as user id.
userID := user.Subject()
sessionRef := identity.GetSessionRef(b.Name(), audience, userID)
b.sessions.Set(*sessionRef, session)
b.logger.WithFields(logrus.Fields{
"session": session,
"ref": *sessionRef,
"username": user.Username(),
"id": userID,
}).Debugln("cs3 backend logon")
return true, &userID, sessionRef, 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 *CS3Backend) GetUser(ctx context.Context, userEntryID string, sessionRef *string, _ map[string]bool) (backends.UserFromBackend, error) {
var session *cs3Session
if s, ok := b.sessions.Get(*sessionRef); ok {
// We have a cached session
session = s.(*cs3Session)
if session != nil {
user, err := newCS3User(session.User())
if err != nil {
return nil, fmt.Errorf("cs3 backend get user failed to process user: %v", err)
}
return user, nil
}
}
// rebuild session
client, err := pool.GetGatewayServiceClient(b.gatewayAddr)
if err != nil {
return nil, err
}
res, err := client.Authenticate(ctx, &cs3gateway.AuthenticateRequest{
Type: "machine",
ClientId: "userid:" + userEntryID,
ClientSecret: b.machineAuthAPIKey,
})
if err != nil {
return nil, fmt.Errorf("cs3 backend get user machine authenticate rpc error: %v", err)
}
if res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
return nil, fmt.Errorf("cs3 backend get user machine authenticate failed with code %s: %s", res.GetStatus().GetCode().String(), res.GetStatus().GetMessage())
}
// cache session
session = createSession(res.GetUser())
b.sessions.Set(*sessionRef, session)
user, err := newCS3User(res.GetUser())
if err != nil {
return nil, fmt.Errorf("cs3 backend get user data error: %v", err)
}
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 *CS3Backend) ResolveUserByUsername(ctx context.Context, username string) (backends.UserFromBackend, error) {
client, err := pool.GetGatewayServiceClient(b.gatewayAddr)
if err != nil {
return nil, err
}
res, err := client.Authenticate(ctx, &cs3gateway.AuthenticateRequest{
Type: "machine",
ClientId: "username:" + username,
ClientSecret: b.machineAuthAPIKey,
})
if err != nil {
return nil, fmt.Errorf("cs3 backend machine authenticate rpc error: %v", err)
}
if res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
return nil, fmt.Errorf("cs3 backend machine authenticate failed with code %s: %s", res.GetStatus().GetCode().String(), res.GetStatus().GetMessage())
}
user, err := newCS3User(res.GetUser())
if err != nil {
return nil, fmt.Errorf("cs3 backend resolve username data error: %v", err)
}
return user, nil
}
// RefreshSession implements the Backend interface.
func (b *CS3Backend) RefreshSession(_ context.Context, _ string, _ *string, _ map[string]any) error {
return nil
}
// DestroySession implements the Backend interface providing destroy CS3 session.
func (b *CS3Backend) DestroySession(_ context.Context, sessionRef *string) error {
b.sessions.Remove(*sessionRef)
return nil
}
// UserClaims implements the Backend interface, providing user specific claims
// for the user specified by the userID.
func (b *CS3Backend) UserClaims(_ string, _ map[string]bool) map[string]any {
return nil
}
// ScopesSupported implements the Backend interface, providing supported scopes
// when running this backend.
func (b *CS3Backend) ScopesSupported() []string {
return b.supportedScopes
}
// ScopesMeta implements the Backend interface, providing meta data for
// supported scopes.
func (b *CS3Backend) ScopesMeta() *scopes.Scopes {
return nil
}
// Name implements the Backend interface.
func (b *CS3Backend) Name() string {
return cs3BackendName
}
@@ -0,0 +1,29 @@
package cs3
import (
"time"
cs3user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
)
// createSession creates a new Session without the server using the provided
// data.
func createSession(u *cs3user.User) *cs3Session {
s := &cs3Session{
u: u,
}
s.when = time.Now()
return s
}
type cs3Session struct {
u *cs3user.User
when time.Time
}
// User returns the cs3 user of the session
func (s *cs3Session) User() *cs3user.User {
return s.u
}
@@ -0,0 +1,74 @@
package cs3
import (
cs3user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
konnect "github.com/libregraph/lico"
)
type cs3User struct {
u *cs3user.User
}
func newCS3User(u *cs3user.User) (*cs3User, error) {
return &cs3User{
u: u,
}, nil
}
// Subject returns the cs3 users opaque id as sub
func (u *cs3User) Subject() string {
return u.u.GetId().GetOpaqueId()
}
// Email returns the cs3 users email
func (u *cs3User) Email() string {
return u.u.GetMail()
}
// EmailVerified returns the cs3 users email verified flag
func (u *cs3User) EmailVerified() bool {
return u.u.GetMailVerified()
}
// Name returns the cs3 users displayname
func (u *cs3User) Name() string {
return u.u.GetDisplayName()
}
// FamilyName always returns "" to fulfill the UserWithProfile interface
func (u *cs3User) FamilyName() string {
return ""
}
// GivenName always returns "" to fulfill the UserWithProfile interface
func (u *cs3User) GivenName() string {
return ""
}
// Username returns the cs3 users username
func (u *cs3User) Username() string {
return u.u.GetUsername()
}
// UniqueID returns the cs3 users opaque id
func (u *cs3User) UniqueID() string {
return u.u.GetId().GetOpaqueId()
}
// BackendClaims returns additional claims the cs3 users provides
func (u *cs3User) BackendClaims() map[string]any {
claims := make(map[string]any)
claims[konnect.IdentifiedUserIDClaim] = u.u.GetId().GetOpaqueId()
return claims
}
// BackendScopes returns nil to fulfill the UserFromBackend interface
func (u *cs3User) BackendScopes() []string {
return nil
}
// RequiredScopes returns nil to fulfill the UserFromBackend interface
func (u *cs3User) RequiredScopes() []string {
return nil
}
+54
View File
@@ -0,0 +1,54 @@
package command
import (
"fmt"
"net/http"
"github.com/qsfera/server/pkg/config/configlog"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/idp/pkg/config"
"github.com/qsfera/server/services/idp/pkg/config/parser"
"github.com/spf13/cobra"
)
// Health is the entrypoint for the health command.
func Health(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "health",
Short: "check health status",
PreRunE: func(cmd *cobra.Command, args []string) error {
return configlog.ReturnError(parser.ParseConfig(cfg))
},
RunE: func(cmd *cobra.Command, args []string) error {
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
resp, err := http.Get(
fmt.Sprintf(
"http://%s/healthz",
cfg.Debug.Addr,
),
)
if err != nil {
logger.Fatal().
Err(err).
Msg("Failed to request health check")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.Fatal().
Int("code", resp.StatusCode).
Msg("Health seems to be in bad state")
}
logger.Debug().
Int("code", resp.StatusCode).
Msg("Health got a good state")
return nil
},
}
}
+37
View File
@@ -0,0 +1,37 @@
package command
import (
"os"
"github.com/qsfera/server/pkg/clihelper"
"github.com/qsfera/server/services/idp/pkg/config"
"github.com/spf13/cobra"
)
// GetCommands provides all commands for this service
func GetCommands(cfg *config.Config) []*cobra.Command {
return []*cobra.Command{
// start this service
Server(cfg),
// interaction with this service
// infos about this service
Health(cfg),
Version(cfg),
}
}
// Execute is the entry point for the idp command.
func Execute(cfg *config.Config) error {
app := clihelper.DefaultApp(&cobra.Command{
Use: "idp",
Short: "Serve IDP API for КуСфера",
})
app.AddCommand(GetCommands(cfg)...)
app.SetArgs(os.Args[1:])
return app.ExecuteContext(cfg.Context)
}
+192
View File
@@ -0,0 +1,192 @@
package command
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/signal"
"path/filepath"
"github.com/qsfera/server/pkg/config/configlog"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/runner"
"github.com/qsfera/server/pkg/tracing"
"github.com/qsfera/server/pkg/version"
"github.com/qsfera/server/services/idp/pkg/config"
"github.com/qsfera/server/services/idp/pkg/config/parser"
"github.com/qsfera/server/services/idp/pkg/metrics"
"github.com/qsfera/server/services/idp/pkg/server/debug"
"github.com/qsfera/server/services/idp/pkg/server/http"
"github.com/spf13/cobra"
)
const _rsaKeySize = 4096
// Server is the entrypoint for the server command.
func Server(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "server",
Short: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
PreRunE: func(cmd *cobra.Command, args []string) error {
err := configlog.ReturnFatal(parser.ParseConfig(cfg))
if err != nil {
return err
}
if cfg.IDP.EncryptionSecretFile != "" {
if err := ensureEncryptionSecretExists(cfg.IDP.EncryptionSecretFile); err != nil {
return err
}
if err := ensureSigningPrivateKeyExists(cfg.IDP.SigningPrivateKeyFiles); err != nil {
return err
}
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
traceProvider, err := tracing.GetTraceProvider(cmd.Context(), cfg.Commons.TracesExporter, cfg.Service.Name)
if err != nil {
return err
}
var cancel context.CancelFunc
if cfg.Context == nil {
cfg.Context, cancel = signal.NotifyContext(context.Background(), runner.StopSignals...)
defer cancel()
}
ctx := cfg.Context
metrics := metrics.New()
metrics.BuildInfo.WithLabelValues(version.GetString()).Set(1)
gr := runner.NewGroup()
{
server, err := http.Server(
http.Logger(logger),
http.Context(ctx),
http.Config(cfg),
http.Metrics(metrics),
http.TraceProvider(traceProvider),
)
if err != nil {
logger.Info().
Err(err).
Str("transport", "http").
Msg("Failed to initialize server")
return err
}
gr.Add(runner.NewGoMicroHttpServerRunner(cfg.Service.Name+".http", server))
}
{
debugServer, err := debug.Server(
debug.Logger(logger),
debug.Context(ctx),
debug.Config(cfg),
)
if err != nil {
logger.Info().Err(err).Str("transport", "debug").Msg("Failed to initialize server")
return err
}
gr.Add(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", debugServer))
}
grResults := gr.Run(ctx)
// return the first non-nil error found in the results
for _, grResult := range grResults {
if grResult.RunnerError != nil {
return grResult.RunnerError
}
}
return nil
},
}
}
func ensureEncryptionSecretExists(path string) error {
_, err := os.Stat(path)
if err == nil {
// If the file exists we can just return
return nil
}
if !errors.Is(err, fs.ErrNotExist) {
return err
}
dir := filepath.Dir(path)
err = os.MkdirAll(dir, 0o700)
if err != nil {
return err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer f.Close()
secret := make([]byte, 32)
_, err = rand.Read(secret)
if err != nil {
return err
}
_, err = io.Copy(f, bytes.NewReader(secret))
if err != nil {
return err
}
return nil
}
func ensureSigningPrivateKeyExists(paths []string) error {
for _, path := range paths {
file, err := os.Stat(path)
if err == nil && file.Size() > 0 {
// If the file exists and is not empty we can just return
return nil
}
if !errors.Is(err, fs.ErrNotExist) && file.Size() > 0 {
return err
}
dir := filepath.Dir(path)
err = os.MkdirAll(dir, 0o700)
if err != nil {
return err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer f.Close()
pk, err := rsa.GenerateKey(rand.Reader, _rsaKeySize)
if err != nil {
return err
}
pb := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(pk),
}
if err := pem.Encode(f, pb); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,49 @@
package command
import (
"fmt"
"os"
"github.com/qsfera/server/pkg/registry"
"github.com/qsfera/server/pkg/version"
"github.com/qsfera/server/services/idp/pkg/config"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/tw"
"github.com/spf13/cobra"
)
// Version prints the service versions of all running instances.
func Version(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "print the version of this binary and the running service instances",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Version: " + version.GetString())
fmt.Printf("Compiled: %s\n", version.Compiled())
fmt.Println("")
reg := registry.GetRegistry()
services, err := reg.GetService(cfg.HTTP.Namespace + "." + cfg.Service.Name)
if err != nil {
fmt.Println(fmt.Errorf("could not get %s services from the registry: %v", cfg.Service.Name, err))
return err
}
if len(services) == 0 {
fmt.Println("No running " + cfg.Service.Name + " service found.")
return nil
}
table := tablewriter.NewTable(os.Stdout, tablewriter.WithHeaderAutoFormat(tw.Off))
table.Header([]string{"Version", "Address", "Id"})
for _, s := range services {
for _, n := range s.Nodes {
table.Append([]string{s.Version, n.Address, n.Id})
}
}
table.Render()
return nil
},
}
}
+120
View File
@@ -0,0 +1,120 @@
package config
import (
"context"
"net/http"
"github.com/qsfera/server/pkg/shared"
)
// Config combines all available configuration parts.
type Config struct {
Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service
Service Service `yaml:"-"`
LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;IDP_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"1.0.0"`
Debug Debug `yaml:"debug"`
HTTP HTTP `yaml:"http"`
Reva *shared.Reva `yaml:"reva"`
MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OC_MACHINE_AUTH_API_KEY;IDP_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used to validate internal requests necessary for the access to resources from other services." introductionVersion:"1.0.0"`
Asset Asset `yaml:"asset"`
IDP Settings `yaml:"idp"`
Clients []Client `yaml:"clients"`
Ldap Ldap `yaml:"ldap"`
Context context.Context `yaml:"-"`
}
// Ldap defines the available LDAP configuration.
type Ldap struct {
URI string `yaml:"uri" env:"OC_LDAP_URI;IDP_LDAP_URI" desc:"Url of the LDAP service to use as IDP." introductionVersion:"1.0.0"`
TLSCACert string `yaml:"cacert" env:"OC_LDAP_CACERT;IDP_LDAP_TLS_CACERT" desc:"Path/File name for the root CA certificate (in PEM format) used to validate TLS server certificates of the LDAP service. If not defined, the root directory derives from $OC_BASE_DATA_PATH/idp." introductionVersion:"1.0.0"`
BindDN string `yaml:"bind_dn" env:"OC_LDAP_BIND_DN;IDP_LDAP_BIND_DN" desc:"LDAP DN to use for simple bind authentication with the target LDAP server." introductionVersion:"1.0.0"`
BindPassword string `yaml:"bind_password" env:"OC_LDAP_BIND_PASSWORD;IDP_LDAP_BIND_PASSWORD" desc:"Password to use for authenticating the 'bind_dn'." introductionVersion:"1.0.0"`
BaseDN string `yaml:"base_dn" env:"OC_LDAP_USER_BASE_DN;IDP_LDAP_BASE_DN" desc:"Search base DN for looking up LDAP users." introductionVersion:"1.0.0"`
Scope string `yaml:"scope" env:"OC_LDAP_USER_SCOPE;IDP_LDAP_SCOPE" desc:"LDAP search scope to use when looking up users. Supported scopes are 'base', 'one' and 'sub'." introductionVersion:"1.0.0"`
LoginAttribute string `yaml:"login_attribute" env:"IDP_LDAP_LOGIN_ATTRIBUTE" desc:"LDAP User attribute to use for login like 'uid'." introductionVersion:"1.0.0"`
EmailAttribute string `yaml:"email_attribute" env:"OC_LDAP_USER_SCHEMA_MAIL;IDP_LDAP_EMAIL_ATTRIBUTE" desc:"LDAP User email attribute like 'mail'." introductionVersion:"1.0.0"`
NameAttribute string `yaml:"name_attribute" env:"OC_LDAP_USER_SCHEMA_USERNAME;IDP_LDAP_NAME_ATTRIBUTE" desc:"LDAP User name attribute like 'displayName'." introductionVersion:"1.0.0"`
UUIDAttribute string `yaml:"uuid_attribute" env:"OC_LDAP_USER_SCHEMA_ID;IDP_LDAP_UUID_ATTRIBUTE" desc:"LDAP User UUID attribute like 'uid'." introductionVersion:"1.0.0"`
UUIDAttributeType string `yaml:"uuid_attribute_type" env:"IDP_LDAP_UUID_ATTRIBUTE_TYPE" desc:"LDAP User uuid attribute type like 'text'." introductionVersion:"1.0.0"`
UserEnabledAttribute string `yaml:"user_enabled_attribute" env:"OC_LDAP_USER_ENABLED_ATTRIBUTE;IDP_USER_ENABLED_ATTRIBUTE" desc:"LDAP Attribute to use as a flag telling if the user is enabled or disabled." introductionVersion:"1.0.0"`
Filter string `yaml:"filter" env:"OC_LDAP_USER_FILTER;IDP_LDAP_FILTER" desc:"LDAP filter to add to the default filters for user search like '(objectclass=qsferaUser)'." introductionVersion:"1.0.0"`
ObjectClass string `yaml:"objectclass" env:"OC_LDAP_USER_OBJECTCLASS;IDP_LDAP_OBJECTCLASS" desc:"LDAP User ObjectClass like 'inetOrgPerson'." introductionVersion:"1.0.0"`
}
// Asset defines the available asset configuration.
type Asset struct {
Path string `yaml:"asset" env:"IDP_ASSET_PATH" desc:"Serve IDP assets from a path on the filesystem instead of the builtin assets." introductionVersion:"1.0.0"`
LoginBackgroundUrl string `yaml:"login-background-url" env:"IDP_LOGIN_BACKGROUND_URL" desc:"Configure an alternative URL to the background image for the login page." introductionVersion:"1.0.0"`
}
type Client struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Trusted bool `yaml:"trusted"`
Secret string `yaml:"secret"`
RedirectURIs []string `yaml:"redirect_uris"`
PostLogoutRedirectURIs []string `yaml:"post_logout_redirect_uris"`
Origins []string `yaml:"origins"`
ApplicationType string `yaml:"application_type"`
}
type Settings struct {
Iss string `yaml:"iss" env:"OC_URL;OC_OIDC_ISSUER;IDP_ISS" desc:"The OIDC issuer URL to use." introductionVersion:"1.0.0"`
IdentityManager string `yaml:"identity_manager" env:"IDP_IDENTITY_MANAGER" desc:"The identity manager implementation to use. Supported identity managers are 'ldap', 'cs3', 'libregraph' and 'guest'." introductionVersion:"1.0.0"`
URIBasePath string `yaml:"uri_base_path" env:"IDP_URI_BASE_PATH" desc:"IDP uri base path (defaults to '')." introductionVersion:"1.0.0"`
SignInURI string `yaml:"sign_in_uri" env:"IDP_SIGN_IN_URI" desc:"IDP sign-in url." introductionVersion:"1.0.0"`
SignedOutURI string `yaml:"signed_out_uri" env:"IDP_SIGN_OUT_URI" desc:"IDP sign-out url." introductionVersion:"1.0.0"`
AuthorizationEndpointURI string `yaml:"authorization_endpoint_uri" env:"IDP_ENDPOINT_URI" desc:"URL of the IDP endpoint." introductionVersion:"1.0.0"`
EndsessionEndpointURI string `yaml:"-"` // unused, not supported by lico-idp
Insecure bool `yaml:"ldap_insecure" env:"OC_LDAP_INSECURE;IDP_INSECURE" desc:"Disable TLS certificate validation for the LDAP connections. Do not set this in production environments." introductionVersion:"1.0.0"`
TrustedProxy []string `yaml:"trusted_proxy"` //TODO: how to configure this via env?
AllowScope []string `yaml:"allow_scope"` // TODO: is this even needed?
AllowClientGuests bool `yaml:"allow_client_guests" env:"IDP_ALLOW_CLIENT_GUESTS" desc:"Allow guest clients to access КуСфера." introductionVersion:"1.0.0"`
AllowDynamicClientRegistration bool `yaml:"allow_dynamic_client_registration" env:"IDP_ALLOW_DYNAMIC_CLIENT_REGISTRATION" desc:"Allow dynamic client registration." introductionVersion:"1.0.0"`
EncryptionSecretFile string `yaml:"encrypt_secret_file" env:"IDP_ENCRYPTION_SECRET_FILE" desc:"Path to the encryption secret file, if unset, a new certificate will be autogenerated upon each restart, thus invalidating all existing sessions. If not defined, the root directory derives from $OC_BASE_DATA_PATH/idp." introductionVersion:"1.0.0"`
Listen string
IdentifierClientDisabled bool `yaml:"-"` // unused
IdentifierClientPath string `yaml:"-"`
IdentifierRegistrationConf string `yaml:"-"`
IdentifierScopesConf string `yaml:"-"` // unused
IdentifierDefaultBannerLogo string
IdentifierDefaultSignInPageText string `yaml:"default_sign_in_page_text" env:"IDP_DEFAULT_SIGNIN_PAGE_TEXT" desc:"" introductionVersion:"2.0.0"`
IdentifierDefaultLogoTargetURI string `yaml:"default_logo_target_uri" env:"IDP_DEFAULT_LOGO_TARGET_URI" desc:"Default logo target URI." introductionVersion:"4.0.0"`
IdentifierDefaultUsernameHintText string
IdentifierUILocales []string
SigningKid string `yaml:"signing_kid" env:"IDP_SIGNING_KID" desc:"Value of the KID (Key ID) field which is used in created tokens to uniquely identify the signing-private-key." introductionVersion:"1.0.0"`
SigningMethod string `yaml:"signing_method" env:"IDP_SIGNING_METHOD" desc:"Signing method of IDP requests like 'PS256'" introductionVersion:"1.0.0"`
SigningPrivateKeyFiles []string `yaml:"signing_private_key_files" env:"IDP_SIGNING_PRIVATE_KEY_FILES" desc:"A list of private key files for signing IDP requests. If not defined, the root directory derives from $OC_BASE_DATA_PATH/idp. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
ValidationKeysPath string `yaml:"validation_keys_path" env:"IDP_VALIDATION_KEYS_PATH" desc:"Path to validation keys for IDP requests." introductionVersion:"1.0.0"`
CookieBackendURI string
CookieNames []string
CookieSameSite http.SameSite
AccessTokenDurationSeconds uint64 `yaml:"access_token_duration_seconds" env:"IDP_ACCESS_TOKEN_EXPIRATION" desc:"'Access token lifespan in seconds (time before an access token is expired).'" introductionVersion:"1.0.0"`
IDTokenDurationSeconds uint64 `yaml:"id_token_duration_seconds" env:"IDP_ID_TOKEN_EXPIRATION" desc:"ID token lifespan in seconds (time before an ID token is expired)." introductionVersion:"1.0.0"`
RefreshTokenDurationSeconds uint64 `yaml:"refresh_token_duration_seconds" env:"IDP_REFRESH_TOKEN_EXPIRATION" desc:"Refresh token lifespan in seconds (time before an refresh token is expired). This also limits the duration of an idle offline session." introductionVersion:"1.0.0"`
DynamicClientSecretDurationSeconds uint64 `yaml:"dynamic_client_secret_duration_seconds" env:"IDP_DYNAMIC_CLIENT_SECRET_DURATION" desc:"Lifespan in seconds of a dynamically registered OIDC client." introductionVersion:"1.0.0"`
}
+9
View File
@@ -0,0 +1,9 @@
package config
// Debug defines the available debug configuration.
type Debug struct {
Addr string `yaml:"addr" env:"IDP_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"1.0.0"`
Token string `yaml:"token" env:"IDP_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"1.0.0"`
Pprof bool `yaml:"pprof" env:"IDP_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"1.0.0"`
Zpages bool `yaml:"zpages" env:"IDP_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,160 @@
package defaults
import (
"net/http"
"path/filepath"
"strings"
"github.com/qsfera/server/pkg/config/defaults"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/pkg/structs"
"github.com/qsfera/server/services/idp/pkg/config"
)
// FullDefaultConfig returns a fully initialized default configuration
func FullDefaultConfig() *config.Config {
cfg := DefaultConfig()
EnsureDefaults(cfg)
Sanitize(cfg)
return cfg
}
// DefaultConfig returns a basic default configuration
func DefaultConfig() *config.Config {
return &config.Config{
Debug: config.Debug{
Addr: "127.0.0.1:9134",
},
HTTP: config.HTTP{
Addr: "127.0.0.1:9130",
Root: "/",
Namespace: "qsfera.web",
TLSCert: filepath.Join(defaults.BaseDataPath(), "idp", "server.crt"),
TLSKey: filepath.Join(defaults.BaseDataPath(), "idp", "server.key"),
TLS: false,
},
Reva: shared.DefaultRevaConfig(),
Service: config.Service{
Name: "idp",
},
IDP: config.Settings{
Iss: "https://localhost:9200",
IdentityManager: "ldap",
URIBasePath: "",
SignInURI: "",
SignedOutURI: "",
AuthorizationEndpointURI: "",
EndsessionEndpointURI: "",
Insecure: false,
TrustedProxy: nil,
AllowScope: nil,
AllowClientGuests: false,
AllowDynamicClientRegistration: false,
EncryptionSecretFile: filepath.Join(defaults.BaseDataPath(), "idp", "encryption.key"),
Listen: "",
IdentifierClientDisabled: true,
IdentifierClientPath: filepath.Join(defaults.BaseDataPath(), "idp"),
IdentifierRegistrationConf: filepath.Join(defaults.BaseDataPath(), "idp", "tmp", "identifier-registration.yaml"),
IdentifierScopesConf: "",
IdentifierDefaultBannerLogo: "",
IdentifierDefaultSignInPageText: "",
IdentifierDefaultLogoTargetURI: "",
IdentifierDefaultUsernameHintText: "",
SigningKid: "private-key",
SigningMethod: "PS256",
SigningPrivateKeyFiles: []string{filepath.Join(defaults.BaseDataPath(), "idp", "private-key.pem")},
ValidationKeysPath: "",
CookieBackendURI: "",
CookieNames: nil,
CookieSameSite: http.SameSiteStrictMode,
AccessTokenDurationSeconds: 60 * 5, // 5 minutes
IDTokenDurationSeconds: 60 * 5, // 5 minutes
RefreshTokenDurationSeconds: 60 * 60 * 24 * 30, // 30 days
DynamicClientSecretDurationSeconds: 0,
},
Clients: []config.Client{
{
ID: "web",
Name: "КуСфера Web App",
Trusted: true,
RedirectURIs: []string{
"{{OC_URL}}/",
"{{OC_URL}}/oidc-callback.html",
"{{OC_URL}}/oidc-silent-redirect.html",
},
Origins: []string{
"{{OC_URL}}",
},
},
{
ID: "QsferaDesktop",
Name: "КуСфера Desktop Client",
ApplicationType: "native",
RedirectURIs: []string{
"http://127.0.0.1",
"http://localhost",
},
},
{
ID: "QsferaAndroid",
Name: "КуСфера Android App",
ApplicationType: "native",
RedirectURIs: []string{
"oc://android.qsfera.eu",
},
PostLogoutRedirectURIs: []string{
"oc://android.qsfera.eu",
},
},
{
ID: "QsferaIOS",
Name: "КуСфера iOS App",
ApplicationType: "native",
RedirectURIs: []string{
"oc://ios.qsfera.eu",
},
PostLogoutRedirectURIs: []string{
"oc://ios.qsfera.eu",
},
},
},
Ldap: config.Ldap{
URI: "ldaps://localhost:9235",
TLSCACert: filepath.Join(defaults.BaseDataPath(), "idm", "ldap.crt"),
BindDN: "uid=idp,ou=sysusers,o=libregraph-idm",
BaseDN: "ou=users,o=libregraph-idm",
Scope: "sub",
LoginAttribute: "uid",
EmailAttribute: "mail",
NameAttribute: "displayName",
UUIDAttribute: "qsferaUUID",
UUIDAttributeType: "text",
Filter: "",
ObjectClass: "inetOrgPerson",
UserEnabledAttribute: "qsferaUserEnabled",
},
}
}
// EnsureDefaults adds default values to the configuration if they are not set yet
func EnsureDefaults(cfg *config.Config) {
if cfg.LogLevel == "" {
cfg.LogLevel = "error"
}
if cfg.Reva == nil && cfg.Commons != nil {
cfg.Reva = structs.CopyOrZeroValue(cfg.Commons.Reva)
}
if cfg.MachineAuthAPIKey == "" && cfg.Commons != nil && cfg.Commons.MachineAuthAPIKey != "" {
cfg.MachineAuthAPIKey = cfg.Commons.MachineAuthAPIKey
}
}
// Sanitize sanitizes the configuration
func Sanitize(cfg *config.Config) {
// sanitize config
if cfg.HTTP.Root != "/" {
cfg.HTTP.Root = strings.TrimSuffix(cfg.HTTP.Root, "/")
}
}
+11
View File
@@ -0,0 +1,11 @@
package config
// HTTP defines the available http configuration.
type HTTP struct {
Addr string `yaml:"addr" env:"IDP_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"`
Root string `yaml:"root" env:"IDP_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
Namespace string `yaml:"-"`
TLSCert string `yaml:"tls_cert" env:"IDP_TRANSPORT_TLS_CERT" desc:"Path/File name of the TLS server certificate (in PEM format) for the IDP service. If not defined, the root directory derives from $OC_BASE_DATA_PATH/idp." introductionVersion:"1.0.0"`
TLSKey string `yaml:"tls_key" env:"IDP_TRANSPORT_TLS_KEY" desc:"Path/File name for the TLS certificate key (in PEM format) for the server certificate to use for the IDP service. If not defined, the root directory derives from $OC_BASE_DATA_PATH/idp." introductionVersion:"1.0.0"`
TLS bool `yaml:"tls" env:"IDP_TLS" desc:"Disable or Enable HTTPS for the communication between the Proxy service and the IDP service. If set to 'true', the key and cert files need to be configured and present." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,49 @@
package parser
import (
"errors"
occfg "github.com/qsfera/server/pkg/config"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/idp/pkg/config"
"github.com/qsfera/server/services/idp/pkg/config/defaults"
"github.com/qsfera/server/pkg/config/envdecode"
)
// ParseConfig loads configuration from known paths.
func ParseConfig(cfg *config.Config) error {
err := occfg.BindSourcesToStructs(cfg.Service.Name, cfg)
if err != nil {
return err
}
defaults.EnsureDefaults(cfg)
// load all env variables relevant to the config in the current context.
if err := envdecode.Decode(cfg); err != nil {
// no environment variable set for this config is an expected "error"
if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
return err
}
}
defaults.Sanitize(cfg)
return Validate(cfg)
}
func Validate(cfg *config.Config) error {
switch cfg.IDP.IdentityManager {
case "cs3":
if cfg.MachineAuthAPIKey == "" {
return shared.MissingMachineAuthApiKeyError(cfg.Service.Name)
}
case "ldap":
if cfg.Ldap.BindPassword == "" {
return shared.MissingLDAPBindPassword(cfg.Service.Name)
}
}
return nil
}
@@ -0,0 +1,7 @@
package config
// Service defines the available service configuration.
type Service struct {
Name string `yaml:"-"`
PasswordResetURI string `yaml:"password_reset_uri" env:"IDP_PASSWORD_RESET_URI" desc:"The URI where a user can reset their password." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,45 @@
package metrics
import "github.com/prometheus/client_golang/prometheus"
var (
// Namespace defines the namespace for the defines metrics.
Namespace = "qsfera"
// Subsystem defines the subsystem for the defines metrics.
Subsystem = "idp"
)
// Metrics defines the available metrics of this service.
type Metrics struct {
// Counter *prometheus.CounterVec
BuildInfo *prometheus.GaugeVec
}
// New initializes the available metrics.
func New() *Metrics {
m := &Metrics{
// Counter: prometheus.NewCounterVec(prometheus.CounterOpts{
// Namespace: Namespace,
// Subsystem: Subsystem,
// Name: "greet_total",
// Help: "How many greeting requests processed",
// }, []string{}),
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "build_info",
Help: "Build Information",
}, []string{"version"}),
}
// prometheus.Register(
// m.Counter,
// )
_ = prometheus.Register(
m.BuildInfo,
)
return m
}
@@ -0,0 +1,55 @@
// Package middleware provides middleware for the idp service.
package middleware
import (
"net/http"
"strings"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
// Static is a middleware that serves static assets.
func Static(root string, fs http.FileSystem, tp trace.TracerProvider) func(http.Handler) http.Handler {
if !strings.HasSuffix(root, "/") {
root = root + "/"
}
static := http.StripPrefix(
root,
http.FileServer(
fs,
),
)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
spanOpts := []trace.SpanStartOption{
trace.WithSpanKind(trace.SpanKindServer),
}
ctx, span := tp.Tracer("idp").Start(r.Context(), "serve static asset", spanOpts...)
defer span.End()
r = r.WithContext(ctx)
// serve the static assets for the identifier web app
if strings.HasPrefix(r.URL.Path, "/signin/v1/static/") {
if strings.HasSuffix(r.URL.Path, "/") {
// but no listing of folders
span.SetAttributes(attribute.KeyValue{Key: "path", Value: attribute.StringValue(r.URL.Path)})
span.SetStatus(codes.Error, "asset not found")
http.NotFound(w, r)
} else {
r.URL.Path = strings.Replace(r.URL.Path, "/signin/v1/static/", "/signin/v1/identifier/static/", 1)
span.SetAttributes(attribute.KeyValue{Key: "server", Value: attribute.StringValue(r.URL.Path)})
static.ServeHTTP(w, r)
}
return
}
span.SetAttributes(attribute.KeyValue{Key: "path", Value: attribute.StringValue(r.URL.Path)})
span.SetStatus(codes.Ok, "ok")
next.ServeHTTP(w, r)
})
}
}
@@ -0,0 +1,50 @@
package debug
import (
"context"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/idp/pkg/config"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Context context.Context
Config *config.Config
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Context provides a function to set the context option.
func Context(val context.Context) Option {
return func(o *Options) {
o.Context = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
@@ -0,0 +1,39 @@
package debug
import (
"net/http"
"net/url"
"github.com/qsfera/server/pkg/checks"
"github.com/qsfera/server/pkg/handlers"
"github.com/qsfera/server/pkg/service/debug"
"github.com/qsfera/server/pkg/version"
)
// Server initializes the debug service and server.
func Server(opts ...Option) (*http.Server, error) {
options := newOptions(opts...)
healthHandlerConfiguration := handlers.NewCheckHandlerConfiguration().
WithLogger(options.Logger).
WithCheck("http reachability", checks.NewHTTPCheck(options.Config.HTTP.Addr))
u, err := url.Parse(options.Config.Ldap.URI)
if err != nil {
return nil, err
}
readyHandlerConfiguration := healthHandlerConfiguration.
WithCheck("ldap reachability", checks.NewTCPCheck(u.Host))
return debug.NewService(
debug.Logger(options.Logger),
debug.Name(options.Config.Service.Name),
debug.Version(version.GetString()),
debug.Address(options.Config.Debug.Addr),
debug.Token(options.Config.Debug.Token),
debug.Pprof(options.Config.Debug.Pprof),
debug.Zpages(options.Config.Debug.Zpages),
debug.Health(handlers.NewCheckHandler(healthHandlerConfiguration)),
debug.Ready(handlers.NewCheckHandler(readyHandlerConfiguration)),
), nil
}
@@ -0,0 +1,86 @@
package http
import (
"context"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/idp/pkg/config"
"github.com/qsfera/server/services/idp/pkg/metrics"
"github.com/spf13/pflag"
"go.opentelemetry.io/otel/trace"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Namespace string
Logger log.Logger
Context context.Context
Config *config.Config
Metrics *metrics.Metrics
Flags []pflag.Flag
TraceProvider trace.TracerProvider
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Context provides a function to set the context option.
func Context(val context.Context) Option {
return func(o *Options) {
o.Context = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
// Metrics provides a function to set the metrics option.
func Metrics(val *metrics.Metrics) Option {
return func(o *Options) {
o.Metrics = val
}
}
// Flags provides a function to set the flags option.
func Flags(flags ...pflag.Flag) Option {
return func(o *Options) {
o.Flags = append(o.Flags, flags...)
}
}
// Namespace provides a function to set the namespace option.
func Namespace(val string) Option {
return func(o *Options) {
o.Namespace = val
}
}
// TraceProvider provides a function to set the trace provider option.
func TraceProvider(val trace.TracerProvider) Option {
return func(o *Options) {
o.TraceProvider = val
}
}
@@ -0,0 +1,85 @@
package http
import (
"fmt"
"os"
chimiddleware "github.com/go-chi/chi/v5/middleware"
pkgcrypto "github.com/qsfera/server/pkg/crypto"
"github.com/qsfera/server/pkg/middleware"
"github.com/qsfera/server/pkg/service/http"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/pkg/version"
svc "github.com/qsfera/server/services/idp/pkg/service/v0"
"go-micro.dev/v4"
)
// Server initializes the http service and server.
func Server(opts ...Option) (http.Service, error) {
options := newOptions(opts...)
if options.Config.HTTP.TLS {
_, certErr := os.Stat(options.Config.HTTP.TLSCert)
_, keyErr := os.Stat(options.Config.HTTP.TLSKey)
if os.IsNotExist(certErr) || os.IsNotExist(keyErr) {
options.Logger.Info().Msgf("Generating certs")
if err := pkgcrypto.GenCert(options.Config.HTTP.TLSCert, options.Config.HTTP.TLSKey, options.Logger); err != nil {
options.Logger.Fatal().Err(err).Msg("Could not setup TLS")
os.Exit(1)
}
}
}
service, err := http.NewService(
http.Logger(options.Logger),
http.Namespace(options.Config.HTTP.Namespace),
http.Name(options.Config.Service.Name),
http.Version(version.GetString()),
http.Address(options.Config.HTTP.Addr),
http.Context(options.Context),
http.Flags(options.Flags...),
http.TLSConfig(shared.HTTPServiceTLS{
Enabled: options.Config.HTTP.TLS,
Cert: options.Config.HTTP.TLSCert,
Key: options.Config.HTTP.TLSKey,
}),
http.TraceProvider(options.TraceProvider),
)
if err != nil {
options.Logger.Error().
Err(err).
Msg("Error initializing http service")
return http.Service{}, fmt.Errorf("could not initialize http service: %w", err)
}
handle := svc.NewService(
svc.Logger(options.Logger),
svc.Config(options.Config),
svc.Middleware(
chimiddleware.RealIP,
chimiddleware.RequestID,
middleware.TraceContext,
middleware.NoCache,
middleware.Version(
options.Config.Service.Name,
version.GetString(),
),
middleware.Logger(
options.Logger,
),
),
svc.TraceProvider(options.TraceProvider),
)
{
handle = svc.NewInstrument(handle, options.Metrics)
handle = svc.NewLoggingHandler(handle, options.Logger)
}
if err := micro.RegisterHandler(service.Server(), handle); err != nil {
return http.Service{}, err
}
return service, nil
}
@@ -0,0 +1,25 @@
package svc
import (
"net/http"
"github.com/qsfera/server/services/idp/pkg/metrics"
)
// NewInstrument returns a service that instruments metrics.
func NewInstrument(next Service, metrics *metrics.Metrics) Service {
return instrument{
next: next,
metrics: metrics,
}
}
type instrument struct {
next Service
metrics *metrics.Metrics
}
// ServeHTTP implements the Service interface.
func (i instrument) ServeHTTP(w http.ResponseWriter, r *http.Request) {
i.next.ServeHTTP(w, r)
}
@@ -0,0 +1,25 @@
package svc
import (
"net/http"
"github.com/qsfera/server/pkg/log"
)
// NewLogging returns a service that logs messages.
func NewLoggingHandler(next Service, logger log.Logger) Service {
return loggingHandler{
next: next,
logger: logger,
}
}
type loggingHandler struct {
next Service
logger log.Logger
}
// ServeHTTP implements the Service interface.
func (l loggingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
l.next.ServeHTTP(w, r)
}
@@ -0,0 +1,59 @@
package svc
import (
"net/http"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/idp/pkg/config"
"go.opentelemetry.io/otel/trace"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Config *config.Config
Middleware []func(http.Handler) http.Handler
TraceProvider trace.TracerProvider
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
// Middleware provides a function to set the middleware option.
func Middleware(val ...func(http.Handler) http.Handler) Option {
return func(o *Options) {
o.Middleware = val
}
}
// TraceProvider provides a function to set the trace provider option.
func TraceProvider(val trace.TracerProvider) Option {
return func(o *Options) {
o.TraceProvider = val
}
}
@@ -0,0 +1,346 @@
package svc
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"path"
"strings"
"github.com/go-chi/chi/v5"
"github.com/gorilla/mux"
"github.com/libregraph/lico/bootstrap"
guestBackendSupport "github.com/libregraph/lico/bootstrap/backends/guest"
ldapBackendSupport "github.com/libregraph/lico/bootstrap/backends/ldap"
libreGraphBackendSupport "github.com/libregraph/lico/bootstrap/backends/libregraph"
licoconfig "github.com/libregraph/lico/config"
"github.com/libregraph/lico/server"
"github.com/qsfera/server/pkg/ldap"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/tracing"
"github.com/qsfera/server/services/idp/pkg/assets"
cs3BackendSupport "github.com/qsfera/server/services/idp/pkg/backends/cs3/bootstrap"
"github.com/qsfera/server/services/idp/pkg/config"
"github.com/qsfera/server/services/idp/pkg/middleware"
"github.com/riandyrn/otelchi"
"go.opentelemetry.io/otel/trace"
"gopkg.in/yaml.v2"
"stash.kopano.io/kgol/rndm"
)
// Service defines the service handlers.
type Service interface {
ServeHTTP(http.ResponseWriter, *http.Request)
}
// NewService returns a service implementation for Service.
func NewService(opts ...Option) Service {
ctx := context.Background()
options := newOptions(opts...)
logger := options.Logger.Logger
assetVFS := assets.New(
assets.Logger(options.Logger),
assets.Config(options.Config),
)
if err := createTemporaryClientsConfig(
options.Config.IDP.IdentifierRegistrationConf,
options.Config.Commons.QsferaURL,
options.Config.Clients,
); err != nil {
logger.Fatal().Err(err).Msg("could not create default config")
}
switch options.Config.IDP.IdentityManager {
case "cs3":
cs3BackendSupport.MustRegister()
if err := initCS3EnvVars(options.Config.Reva.Address, options.Config.MachineAuthAPIKey); err != nil {
logger.Fatal().Err(err).Msg("could not initialize cs3 backend env vars")
}
case "ldap":
if err := ldap.WaitForCA(options.Logger, options.Config.IDP.Insecure, options.Config.Ldap.TLSCACert); err != nil {
logger.Fatal().Err(err).Msg("The configured LDAP CA cert does not exist")
}
if options.Config.IDP.Insecure {
// force CACert to be empty to avoid lico try to load it
options.Config.Ldap.TLSCACert = ""
}
ldapBackendSupport.MustRegister()
if err := initLicoInternalLDAPEnvVars(&options.Config.Ldap); err != nil {
logger.Fatal().Err(err).Msg("could not initialize ldap env vars")
}
default:
guestBackendSupport.MustRegister()
libreGraphBackendSupport.MustRegister()
}
idpSettings := bootstrap.Settings{
Iss: options.Config.IDP.Iss,
IdentityManager: options.Config.IDP.IdentityManager,
URIBasePath: options.Config.IDP.URIBasePath,
SignInURI: options.Config.IDP.SignInURI,
SignedOutURI: options.Config.IDP.SignedOutURI,
AuthorizationEndpointURI: options.Config.IDP.AuthorizationEndpointURI,
EndsessionEndpointURI: options.Config.IDP.EndsessionEndpointURI,
Insecure: options.Config.IDP.Insecure,
TrustedProxy: options.Config.IDP.TrustedProxy,
AllowScope: options.Config.IDP.AllowScope,
AllowClientGuests: options.Config.IDP.AllowClientGuests,
AllowDynamicClientRegistration: options.Config.IDP.AllowDynamicClientRegistration,
EncryptionSecretFile: options.Config.IDP.EncryptionSecretFile,
Listen: options.Config.IDP.Listen,
IdentifierClientDisabled: options.Config.IDP.IdentifierClientDisabled,
IdentifierClientPath: options.Config.IDP.IdentifierClientPath,
IdentifierRegistrationConf: options.Config.IDP.IdentifierRegistrationConf,
IdentifierScopesConf: options.Config.IDP.IdentifierScopesConf,
IdentifierDefaultBannerLogo: options.Config.IDP.IdentifierDefaultBannerLogo,
IdentifierDefaultSignInPageText: options.Config.IDP.IdentifierDefaultSignInPageText,
IdentifierDefaultLogoTargetURI: options.Config.IDP.IdentifierDefaultLogoTargetURI,
IdentifierDefaultUsernameHintText: options.Config.IDP.IdentifierDefaultUsernameHintText,
IdentifierUILocales: options.Config.IDP.IdentifierUILocales,
SigningKid: options.Config.IDP.SigningKid,
SigningMethod: options.Config.IDP.SigningMethod,
SigningPrivateKeyFiles: options.Config.IDP.SigningPrivateKeyFiles,
ValidationKeysPath: options.Config.IDP.ValidationKeysPath,
CookieBackendURI: options.Config.IDP.CookieBackendURI,
CookieNames: options.Config.IDP.CookieNames,
CookieSameSite: options.Config.IDP.CookieSameSite,
AccessTokenDurationSeconds: options.Config.IDP.AccessTokenDurationSeconds,
IDTokenDurationSeconds: options.Config.IDP.IDTokenDurationSeconds,
RefreshTokenDurationSeconds: options.Config.IDP.RefreshTokenDurationSeconds,
DyamicClientSecretDurationSeconds: options.Config.IDP.DynamicClientSecretDurationSeconds,
}
bs, err := bootstrap.Boot(ctx, &idpSettings, &licoconfig.Config{
Logger: log.LogrusWrap(logger),
})
if err != nil {
logger.Fatal().Err(err).Msg("could not bootstrap idp")
}
managers := bs.Managers()
routes := []server.WithRoutes{managers.Must("identity").(server.WithRoutes)}
handlers := managers.Must("handler").(http.Handler)
svc := IDP{
logger: options.Logger,
config: options.Config,
assets: assetVFS,
tp: options.TraceProvider,
}
svc.initMux(ctx, routes, handlers, options)
return svc
}
type temporaryClientConfig struct {
Clients []config.Client `yaml:"clients"`
}
func createTemporaryClientsConfig(filePath, qsferaURL string, clients []config.Client) error {
folder := path.Dir(filePath)
if _, err := os.Stat(folder); os.IsNotExist(err) {
if err := os.MkdirAll(folder, 0o700); err != nil {
return err
}
}
for i, client := range clients {
for i, entry := range client.RedirectURIs {
client.RedirectURIs[i] = strings.ReplaceAll(entry, "{{OC_URL}}", strings.TrimRight(qsferaURL, "/"))
}
for i, entry := range client.Origins {
client.Origins[i] = strings.ReplaceAll(entry, "{{OC_URL}}", strings.TrimRight(qsferaURL, "/"))
}
clients[i] = client
}
c := temporaryClientConfig{
Clients: clients,
}
conf, err := yaml.Marshal(c)
if err != nil {
return err
}
confOnDisk, err := os.Create(filePath)
if err != nil {
return err
}
defer confOnDisk.Close()
err = os.WriteFile(filePath, conf, 0o600)
if err != nil {
return err
}
return nil
}
// Init cs3 backend vars which are currently not accessible via idp api
func initCS3EnvVars(cs3Addr, machineAuthAPIKey string) error {
defaults := map[string]string{
"CS3_GATEWAY": cs3Addr,
"CS3_MACHINE_AUTH_API_KEY": machineAuthAPIKey,
}
for k, v := range defaults {
if err := os.Setenv(k, v); err != nil {
return fmt.Errorf("could not set cs3 env var %s=%s", k, v)
}
}
return nil
}
// Init ldap backend vars which are currently not accessible via idp api
func initLicoInternalLDAPEnvVars(ldap *config.Ldap) error {
filter := fmt.Sprintf("(objectclass=%s)", ldap.ObjectClass)
var needsAnd bool
if ldap.Filter != "" {
filter += ldap.Filter
needsAnd = true
}
if ldap.UserEnabledAttribute != "" {
// Using a (!(enabled=FALSE)) filter here to allow user without
// any value for the enable flag to log in
filter += fmt.Sprintf("(!(%s=FALSE))", ldap.UserEnabledAttribute)
needsAnd = true
}
if needsAnd {
filter = fmt.Sprintf("(&%s)", filter)
}
defaults := map[string]string{
"LDAP_URI": ldap.URI,
"LDAP_BINDDN": ldap.BindDN,
"LDAP_BINDPW": ldap.BindPassword,
"LDAP_BASEDN": ldap.BaseDN,
"LDAP_SCOPE": ldap.Scope,
"LDAP_LOGIN_ATTRIBUTE": ldap.LoginAttribute,
"LDAP_EMAIL_ATTRIBUTE": ldap.EmailAttribute,
"LDAP_NAME_ATTRIBUTE": ldap.NameAttribute,
"LDAP_UUID_ATTRIBUTE": ldap.UUIDAttribute,
"LDAP_SUB_ATTRIBUTES": ldap.UUIDAttribute,
"LDAP_UUID_ATTRIBUTE_TYPE": ldap.UUIDAttributeType,
"LDAP_FILTER": filter,
}
if ldap.TLSCACert != "" {
defaults["LDAP_TLS_CACERT"] = ldap.TLSCACert
}
for k, v := range defaults {
if err := os.Setenv(k, v); err != nil {
return fmt.Errorf("could not set ldap env var %s=%s", k, v)
}
}
return nil
}
// IDP defines implements the business logic for Service.
type IDP struct {
logger log.Logger
config *config.Config
mux *chi.Mux
assets http.FileSystem
tp trace.TracerProvider
}
// initMux initializes the internal idp gorilla mux and mounts it in to an КуСфера chi-router
func (idp *IDP) initMux(ctx context.Context, r []server.WithRoutes, h http.Handler, options Options) {
gm := mux.NewRouter()
for _, route := range r {
route.AddRoutes(ctx, gm)
}
// Delegate rest to provider which is also a handler.
if h != nil {
gm.NotFoundHandler = h
}
idp.mux = chi.NewMux()
idp.mux.Use(options.Middleware...)
idp.mux.Use(middleware.Static(
"/signin/v1/",
assets.New(
assets.Logger(options.Logger),
assets.Config(options.Config),
),
idp.tp,
))
idp.mux.Use(
otelchi.Middleware(
"idp",
otelchi.WithChiRoutes(idp.mux),
otelchi.WithTracerProvider(idp.tp),
otelchi.WithPropagators(tracing.GetPropagator()),
),
)
// handle / | index.html with a template that needs to have the BASE_PREFIX replaced
idp.mux.Get("/signin/v1/identifier", idp.Index())
idp.mux.Get("/signin/v1/identifier/", idp.Index())
idp.mux.Get("/signin/v1/identifier/index.html", idp.Index())
idp.mux.Mount("/", gm)
_ = chi.Walk(idp.mux, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
options.Logger.Debug().Str("method", method).Str("route", route).Int("middlewares", len(middlewares)).Msg("serving endpoint")
return nil
})
}
// ServeHTTP implements the Service interface.
func (idp IDP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
idp.mux.ServeHTTP(w, r)
}
// Index renders the static html with templated variables.
func (idp IDP) Index() http.HandlerFunc {
f, err := idp.assets.Open("/identifier/index.html")
if err != nil {
idp.logger.Fatal().Err(err).Msg("Could not open index template")
}
template, err := io.ReadAll(f)
if err != nil {
idp.logger.Fatal().Err(err).Msg("Could not read index template")
}
if err = f.Close(); err != nil {
idp.logger.Fatal().Err(err).Msg("Could not close body")
}
// TODO add environment variable to make the path prefix configurable
pp := "/signin/v1"
indexHTML := bytes.Replace(template, []byte("__PATH_PREFIX__"), []byte(pp), 1)
background := idp.config.Asset.LoginBackgroundUrl
indexHTML = bytes.Replace(template, []byte("__BG_IMG_URL__"), []byte(background), 1)
nonce := rndm.GenerateRandomString(32)
indexHTML = bytes.Replace(indexHTML, []byte("__CSP_NONCE__"), []byte(nonce), 1)
indexHTML = bytes.Replace(indexHTML, []byte("__PASSWORD_RESET_LINK__"), []byte(idp.config.Service.PasswordResetURI), 1)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
if _, err := w.Write(indexHTML); err != nil {
idp.logger.Error().Err(err).Msg("could not write to response writer")
}
})
}