Compare commits

...

10 commits

Author SHA1 Message Date
9d2d49425d chore: refactor serve command
All checks were successful
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2023-11-08 19:32:56 +01:00
69a07ce076 feat #43: add cli command to manage backends 2023-11-08 19:32:56 +01:00
550622e512 feat #43: add service to handle backends in the storage 2023-11-08 19:32:56 +01:00
e4497cad8c Add envrc to facilitate development 2023-11-08 19:32:56 +01:00
137789e2a1 Fix docker image not compatible with non static compilation
All checks were successful
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2023-11-04 17:59:34 +01:00
2e34244fcf feat #41: add cobra and make the server use it 2023-11-04 17:59:34 +01:00
673eaeb10d feat #34: add error UI
All checks were successful
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2023-11-04 09:31:29 +01:00
d18c91da23 feat #32: improve approval UI
Some checks failed
ci/woodpecker/push/test Pipeline failed
ci/woodpecker/push/deploy unknown status
2023-11-04 09:18:34 +01:00
0083624204 feat #6: Add style for auth page
Some checks failed
ci/woodpecker/push/test Pipeline failed
ci/woodpecker/push/deploy unknown status
2023-11-04 09:01:26 +01:00
fd8caf98a6 feat #6: Add logo to all page headers 2023-11-04 09:01:26 +01:00
24 changed files with 1066 additions and 218 deletions

18
.envrc Normal file
View file

@ -0,0 +1,18 @@
# Can be debug,info,warning,error
export LOG_LEVEL=debug
# Can be net,unix
export SERVER_MODE=net
export SERVER_HOST="0.0.0.0"
export SERVER_PORT="5000"
# SERVER_SOCK_PATH = ""
export STORAGE_TYPE="sqlite"
export STORAGE_FILEPATH="./build/polyculeconnect.db"
# STORAGE_HOST = "127.0.0.1"
# STORAGE_PORT = "5432"
# STORAGE_DB = "polyculeconnect"
# STORAGE_USER = "polyculeconnect"
# STORAGE_PASSWORD = "polyculeconnect"
# STORAGE_SSL_MODE = "disable"
# STORAGE_SSL_CA_FILE = ""

View file

@ -6,7 +6,9 @@ COPY polyculeconnect ./
RUN make build
# Replace with from scratch later on
FROM --platform=$TARGETPLATFORM alpine:latest
FROM --platform=$TARGETPLATFORM debian:latest
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get -qq install ca-certificates
WORKDIR /root
COPY --from=builder go/src/git.faercol.me/polyculeconnect/build/polyculeconnect ./
ADD polyculeconnect/robots.txt /root/
@ -16,4 +18,4 @@ ADD polyculeconnect/templates /root/templates/
VOLUME [ "/config" ]
ENTRYPOINT [ "./polyculeconnect" ]
CMD [ "-config", "/config/config.json" ]
CMD [ "serve", "--config", "/config/config.json" ]

View file

@ -0,0 +1,66 @@
package cmd
import (
"fmt"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/cmd/utils"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/services"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/services/backend"
"github.com/spf13/cobra"
)
var (
backendID string
backendName string
backendIssuer string
)
var backendAddCmd = &cobra.Command{
Use: "add",
Short: "Add a new backend to the storage",
Long: `Add a new backend to the storage.
Parameters to provide:
- id: Unique ID to represent the backend in the storage
- name: Human readable name to represent the backend. It will be used by
the user in the authentication page to select a backend during
authentication
- issuer: Full hostname of the OIDC provider, e.g. 'https://github.com'`,
Run: func(cmd *cobra.Command, args []string) {
addNewBackend()
},
}
func addNewBackend() {
c := utils.InitConfig("")
s := utils.InitStorage(c)
clientID, clientSecret, err := services.GenerateClientIDSecret()
if err != nil {
utils.Failf("Failed to generate client id or secret: %s", err.Error())
}
backendConf := backend.BackendConfig{
Issuer: backendIssuer,
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURI: c.RedirectURI(),
ID: backendID,
Name: backendName,
}
if err := backend.New(s).AddBackend(backendConf); err != nil {
utils.Failf("Failed to add new backend to storage: %s", err.Error())
}
fmt.Printf("New backend %s added.\n", backendName)
printProperty("Client ID", clientID)
printProperty("Client secret", clientSecret)
}
func init() {
backendCmd.AddCommand(backendAddCmd)
backendAddCmd.Flags().StringVarP(&backendID, "id", "i", "", "ID to identify the backend in the storage")
backendAddCmd.Flags().StringVarP(&backendName, "name", "n", "", "Name to represent the backend")
backendAddCmd.Flags().StringVarP(&backendIssuer, "issuer", "d", "", "Full hostname of the backend")
}

View file

@ -0,0 +1,30 @@
package cmd
import (
"fmt"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/cmd"
"github.com/spf13/cobra"
)
var backendCmd = &cobra.Command{
Use: "backend",
Short: "A brief description of your command",
Long: `A longer description that spans multiple lines and likely contains examples
and usage of using your command. For example:
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("backend called")
},
}
func printProperty(key, value string) {
fmt.Printf("\t- %s: %s\n", key, value)
}
func init() {
cmd.RootCmd.AddCommand(backendCmd)
}

View file

@ -0,0 +1,38 @@
package cmd
import (
"errors"
"fmt"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/cmd/utils"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/services/backend"
"github.com/dexidp/dex/storage"
"github.com/spf13/cobra"
)
var backendRemoveCmd = &cobra.Command{
Use: "remove <backend_id>",
Short: "Remove a backend",
Long: `Remove the backend with the given ID from the database.
If the backend is not found in the database, no error is returned`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
removeBackend(args[0])
},
}
func removeBackend(backendID string) {
s := utils.InitStorage(utils.InitConfig(""))
if err := backend.New(s).RemoveBackend(backendID); err != nil {
if !errors.Is(err, storage.ErrNotFound) {
utils.Failf("Failed to remove backend: %s", err.Error())
}
}
fmt.Println("Backend deleted")
}
func init() {
backendCmd.AddCommand(backendRemoveCmd)
}

View file

@ -0,0 +1,67 @@
package cmd
import (
"errors"
"fmt"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/cmd/utils"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/services/backend"
"github.com/dexidp/dex/storage"
"github.com/spf13/cobra"
)
var backendShowCmd = &cobra.Command{
Use: "show [backend_id]",
Short: "Display installed backends",
Long: `Display the configuration for the backends.
Pass the commands without arguments to display the list of currently installed backends
Pass the optional 'id' argument to display the configuration for this specific backend`,
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
s := utils.InitStorage(utils.InitConfig(""))
if len(args) > 0 {
showBackend(args[0], backend.New(s))
} else {
listBackends(backend.New(s))
}
},
}
func showBackend(backendId string, backendService backend.Service) {
backendConfig, err := backendService.GetBackend(backendId)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
utils.Failf("Backend with ID %s does not exist\n", backendId)
}
utils.Failf("Failed to get config for backend %s: %q\n", backendId, err.Error())
}
fmt.Println("Backend config:")
printProperty("ID", backendConfig.ID)
printProperty("Name", backendConfig.Name)
printProperty("Issuer", backendConfig.Issuer)
printProperty("Client ID", backendConfig.ClientID)
printProperty("Client secret", backendConfig.ClientSecret)
printProperty("Redirect URI", backendConfig.RedirectURI)
}
func listBackends(backendService backend.Service) {
backends, err := backendService.ListBackends()
if err != nil {
utils.Failf("Failed to list backends: %q\n", err.Error())
}
if len(backends) == 0 {
fmt.Println("No backend configured")
return
}
for _, b := range backends {
fmt.Printf("\t - %s: (%s) - %s\n", b.ID, b.Name, b.Issuer)
}
}
func init() {
backendCmd.AddCommand(backendShowCmd)
}

View file

@ -0,0 +1,42 @@
package cmd
import (
"os"
"github.com/spf13/cobra"
)
// RootCmd represents the base command when called without any subcommands
var RootCmd = &cobra.Command{
Use: "polyculeconnect",
Short: "You're in their DMs, I'm in their SSO",
Long: `PolyculeConnect is a SSO OpenIDConnect provider which allows multiple authentication backends,
and enables authentication federation among several infrastructures.`,
// Uncomment the following line if your bare application
// has an action associated with it:
// Run: func(cmd *cobra.Command, args []string) { },
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := RootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.polyculeconnect.yaml)")
// Cobra also supports local flags, which will only run
// when this action is called directly.
// rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
// Disable the default `completion` command to generate the autocompletion files
RootCmd.Root().CompletionOptions.DisableDefaultCmd = true
}

View file

@ -0,0 +1,141 @@
package serve
import (
"context"
"os"
"os/signal"
"time"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/cmd"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/cmd/utils"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/connector"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/logger"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/server"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/services"
dex_server "github.com/dexidp/dex/server"
"github.com/prometheus/client_golang/prometheus"
"github.com/spf13/cobra"
)
var configPath string
const stopTimeout = 10 * time.Second
// serveCmd represents the serve command
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start the web server",
Long: `Start the PolyculeConnect web server using the configuration defined through environment
variables`,
Run: func(cmd *cobra.Command, args []string) {
serve()
},
}
func serve() {
mainCtx, cancel := context.WithCancel(context.Background())
conf := utils.InitConfig(configPath)
logger.Init(conf.LogLevel)
logger.L.Infof("Initialized logger with level %v", conf.LogLevel)
storageType := utils.InitStorage(conf)
logger.L.Infof("Initialized storage backend %q", conf.StorageType)
dexConf := dex_server.Config{
Web: dex_server.WebConfig{
Dir: "./",
Theme: "default",
},
Storage: storageType,
Issuer: conf.OpenConnectConfig.Issuer,
SupportedResponseTypes: []string{"code"},
SkipApprovalScreen: false,
AllowedOrigins: []string{"*"},
Logger: logger.L,
PrometheusRegistry: prometheus.NewRegistry(),
}
logger.L.Info("Initializing authentication backends")
dex_server.ConnectorsConfig[connector.TypeRefuseAll] = func() dex_server.ConnectorConfig { return new(connector.RefuseAllConfig) }
connectors, err := dexConf.Storage.ListConnectors()
if err != nil {
logger.L.Fatalf("Failed to get existing connectors: %s", err.Error())
}
var connectorIDs []string
for _, conn := range connectors {
connectorIDs = append(connectorIDs, conn.ID)
}
if err := services.AddDefaultBackend(storageType); err != nil {
logger.L.Errorf("Failed to add connector for backend RefuseAll to stage: %s", err.Error())
}
for _, backend := range conf.OpenConnectConfig.BackendConfigs {
if err := services.CreateConnector(backend, &dexConf, connectorIDs); err != nil {
logger.L.Errorf("Failed to add connector for backend %q to stage: %s", backend.Name, err.Error())
continue
}
}
logger.L.Info("Initializing clients")
for _, client := range conf.OpenConnectConfig.ClientConfigs {
if err := dexConf.Storage.CreateClient(*client); err != nil {
logger.L.Errorf("Failed to add client to storage: %s", err.Error())
}
}
dexSrv, err := dex_server.NewServer(mainCtx, dexConf)
if err != nil {
logger.L.Fatalf("Failed to init dex server: %s", err.Error())
}
logger.L.Info("Initializing server")
s, err := server.New(conf, dexSrv, logger.L)
if err != nil {
logger.L.Fatalf("Failed to initialize server: %s", err.Error())
}
go s.Run(mainCtx)
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
logger.L.Info("Application successfully started")
logger.L.Debug("Waiting for stop signal")
select {
case <-s.Done():
logger.L.Fatal("Unexpected exit from server")
case <-c:
logger.L.Info("Stopping main application")
cancel()
}
logger.L.Debugf("Waiting %v for all daemons to stop", stopTimeout)
select {
case <-time.After(stopTimeout):
logger.L.Fatalf("Failed to stop all daemons in the expected time")
case <-s.Done():
logger.L.Info("web server successfully stopped")
}
logger.L.Info("Application successfully stopped")
os.Exit(0)
}
func init() {
cmd.RootCmd.AddCommand(serveCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// serveCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// serveCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
serveCmd.Flags().StringVarP(&configPath, "config", "c", "config.json", "Path to the JSON configuration file")
}

View file

@ -0,0 +1,40 @@
package utils
import (
"fmt"
"os"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/config"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/services"
"github.com/dexidp/dex/storage"
)
// Fail displays the given error to stderr and exits the program with a returncode 1
func Fail(errMsg string) {
fmt.Fprintln(os.Stderr, errMsg)
os.Exit(1)
}
// Fail displays the given formatted error to stderr and exits the program with a returncode 1
func Failf(msg string, args ...any) {
fmt.Fprintf(os.Stderr, msg+"\n", args...)
os.Exit(1)
}
// InitConfig inits the configuration, and fails the program if an error occurs
func InitConfig(configPath string) *config.AppConfig {
conf, err := config.New(configPath)
if err != nil {
Failf("Failed to load the configuration: %s", err.Error())
}
return conf
}
// Initstorage inits the storage, and fails the program if an error occurs
func InitStorage(config *config.AppConfig) storage.Storage {
s, err := services.InitStorage(config)
if err != nil {
Failf("Failed to init the storage: %s", err.Error())
}
return s
}

View file

@ -66,6 +66,7 @@ const (
defaultStorageSSLCaFile = ""
)
// Deprecated: remove when we finally drop the JSON config
type BackendConfig struct {
Config *oidc.Config `json:"config"`
Name string `json:"name"`
@ -145,6 +146,10 @@ func (ac *AppConfig) getConfFromEnv() {
ac.StorageConfig.Ssl.Mode = getStringFromEnv(varStorageSSLMode, defaultStorageSSLMode)
}
func (ac *AppConfig) RedirectURI() string {
return ac.OpenConnectConfig.Issuer + "/callback"
}
func New(filepath string) (*AppConfig, error) {
var conf AppConfig
conf.StorageConfig = &StorageConfig{}

View file

@ -6,8 +6,18 @@ import (
"github.com/dexidp/dex/connector"
"github.com/dexidp/dex/pkg/log"
"github.com/dexidp/dex/storage"
)
const TypeRefuseAll = "refuseAll"
var RefuseAllConnectorConfig storage.Connector = storage.Connector{
ID: "null",
Name: "RefuseAll",
Type: TypeRefuseAll,
Config: nil,
}
type RefuseAllConfig struct{}
func (c *RefuseAllConfig) Open(id string, logger log.Logger) (connector.Connector, error) {

View file

@ -6,6 +6,7 @@ require (
github.com/dexidp/dex v0.0.0-20231014000322-089f374d4f3e
github.com/prometheus/client_golang v1.17.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.7.0
github.com/stretchr/testify v1.8.4
)
@ -38,6 +39,7 @@ require (
github.com/gorilla/mux v1.8.0 // indirect
github.com/huandu/xstrings v1.3.3 // indirect
github.com/imdario/mergo v0.3.11 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jonboulle/clockwork v0.2.2 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
@ -53,6 +55,7 @@ require (
github.com/russellhaering/goxmldsig v1.4.0 // indirect
github.com/shopspring/decimal v1.2.0 // indirect
github.com/spf13/cast v1.4.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 // indirect

View file

@ -28,6 +28,7 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-oidc/v3 v3.6.0 h1:AKVxfYw1Gmkn/w96z0DbT/B/xFnzTd3MkZvWLjF4n/o=
github.com/coreos/go-oidc/v3 v3.6.0/go.mod h1:ZpHUsHBucTUj6WOkrP4E20UPynbLZzhTQ1XKCXkxyPc=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@ -97,6 +98,8 @@ github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4
github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@ -140,6 +143,7 @@ github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6po
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys=
github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
@ -147,6 +151,10 @@ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA=
github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=

View file

@ -1,185 +1,11 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"os/signal"
"time"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/config"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/connector"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/logger"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/server"
"github.com/dexidp/dex/connector/oidc"
dex_server "github.com/dexidp/dex/server"
"github.com/dexidp/dex/storage"
"github.com/dexidp/dex/storage/memory"
"github.com/dexidp/dex/storage/sql"
"github.com/prometheus/client_golang/prometheus"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/cmd"
_ "git.faercol.me/faercol/polyculeconnect/polyculeconnect/cmd/backend"
_ "git.faercol.me/faercol/polyculeconnect/polyculeconnect/cmd/serve"
)
const stopTimeout = 10 * time.Second
type cliArgs struct {
configPath string
}
func parseArgs() *cliArgs {
configPath := flag.String("config", "", "Path to the JSON configuration file")
flag.Parse()
return &cliArgs{
configPath: *configPath,
}
}
func initStorage(conf *config.AppConfig) (storage.Storage, error) {
var storageType storage.Storage
var err error
switch conf.StorageType {
case "memory":
storageType = memory.New(logger.L)
case "sqlite":
sqlconfig := sql.SQLite3{
File: conf.StorageConfig.File,
}
storageType, err = sqlconfig.Open(logger.L)
if err != nil {
logger.L.Fatalf("Failed to initialize sqlite backend: %s", err.Error())
}
default:
return storageType, fmt.Errorf("unsupported storage backend type: %s", conf.StorageType)
}
return storageType, nil
}
func createConnector(backend *config.BackendConfig, dexConf *dex_server.Config, connectorIDs []string) error {
for _, id := range connectorIDs {
if id == backend.ID {
return nil
}
}
backendConfJson, err := json.Marshal(backend.Config)
if err != nil {
return fmt.Errorf("failed to serialize oidc config for backend %q: %s", backend.Name, err.Error())
}
return dexConf.Storage.CreateConnector(storage.Connector{
ID: backend.ID,
Name: backend.Name,
Type: string(backend.Type),
Config: backendConfJson,
})
}
func main() {
args := parseArgs()
mainCtx, cancel := context.WithCancel(context.Background())
conf, err := config.New(args.configPath)
if err != nil {
panic(err)
}
logger.Init(conf.LogLevel)
logger.L.Infof("Initialized logger with level %v", conf.LogLevel)
storageType, err := initStorage(conf)
if err != nil {
logger.L.Fatalf("Failed to initialize storage backend: %s", err.Error())
}
logger.L.Infof("Initialized storage backend %q", conf.StorageType)
dexConf := dex_server.Config{
Web: dex_server.WebConfig{
Dir: "./",
Theme: "default",
},
Storage: storageType,
Issuer: conf.OpenConnectConfig.Issuer,
SupportedResponseTypes: []string{"code"},
SkipApprovalScreen: false,
AllowedOrigins: []string{"*"},
Logger: logger.L,
PrometheusRegistry: prometheus.NewRegistry(),
}
logger.L.Info("Initializing authentication backends")
dex_server.ConnectorsConfig["refuseAll"] = func() dex_server.ConnectorConfig { return new(connector.RefuseAllConfig) }
connectors, err := dexConf.Storage.ListConnectors()
if err != nil {
logger.L.Fatalf("Failed to get existing connectors: %s", err.Error())
}
var connectorIDs []string
for _, conn := range connectors {
connectorIDs = append(connectorIDs, conn.ID)
}
backend := config.BackendConfig{
Config: &oidc.Config{},
Name: "RefuseAll",
ID: "null",
Type: "refuseAll",
}
if err := createConnector(&backend, &dexConf, connectorIDs); err != nil {
logger.L.Errorf("Failed to add connector for backend RefuseAll to stage: %s", err.Error())
}
for _, backend := range conf.OpenConnectConfig.BackendConfigs {
if err := createConnector(backend, &dexConf, connectorIDs); err != nil {
logger.L.Errorf("Failed to add connector for backend %q to stage: %s", backend.Name, err.Error())
continue
}
}
logger.L.Info("Initializing clients")
for _, client := range conf.OpenConnectConfig.ClientConfigs {
if err := dexConf.Storage.CreateClient(*client); err != nil {
logger.L.Errorf("Failed to add client to storage: %s", err.Error())
}
}
dexSrv, err := dex_server.NewServer(mainCtx, dexConf)
if err != nil {
logger.L.Fatalf("Failed to init dex server: %s", err.Error())
}
logger.L.Info("Initializing server")
s, err := server.New(conf, dexSrv, logger.L)
if err != nil {
logger.L.Fatalf("Failed to initialize server: %s", err.Error())
}
go s.Run(mainCtx)
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
logger.L.Info("Application successfully started")
logger.L.Debug("Waiting for stop signal")
select {
case <-s.Done():
logger.L.Fatal("Unexpected exit from server")
case <-c:
logger.L.Info("Stopping main application")
cancel()
}
logger.L.Debugf("Waiting %v for all daemons to stop", stopTimeout)
select {
case <-time.After(stopTimeout):
logger.L.Fatalf("Failed to stop all daemons in the expected time")
case <-s.Done():
logger.L.Info("web server successfully stopped")
}
logger.L.Info("Application successfully stopped")
os.Exit(0)
cmd.Execute()
}

View file

@ -0,0 +1,123 @@
package backend
import (
"encoding/json"
"errors"
"fmt"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/connector"
"github.com/dexidp/dex/connector/oidc"
"github.com/dexidp/dex/storage"
)
var ErrUnsupportedType = errors.New("unsupported connector type")
type BackendConfig struct {
ID string
Name string
Issuer string
ClientID string
ClientSecret string
RedirectURI string
}
func (bc *BackendConfig) OIDC() oidc.Config {
return oidc.Config{
Issuer: bc.Issuer,
ClientID: bc.ClientID,
ClientSecret: bc.ClientSecret,
RedirectURI: bc.RedirectURI,
}
}
func (bc *BackendConfig) Storage() (storage.Connector, error) {
oidcJSON, err := json.Marshal(bc.OIDC())
if err != nil {
return storage.Connector{}, fmt.Errorf("failed to serialize oidc config: %w", err)
}
return storage.Connector{
ID: bc.ID,
Type: "oidc",
Name: bc.Name,
Config: oidcJSON,
}, nil
}
func (bc *BackendConfig) FromConnector(connector storage.Connector) error {
var oidc oidc.Config
if connector.Type != "oidc" {
return ErrUnsupportedType
}
if err := json.Unmarshal(connector.Config, &oidc); err != nil {
return fmt.Errorf("invalid OIDC config: %w", err)
}
bc.ID = connector.ID
bc.Name = connector.Name
bc.ClientID = oidc.ClientID
bc.ClientSecret = oidc.ClientSecret
bc.Issuer = oidc.Issuer
bc.RedirectURI = oidc.RedirectURI
return nil
}
type Service interface {
ListBackends() ([]BackendConfig, error)
GetBackend(id string) (BackendConfig, error)
AddBackend(config BackendConfig) error
RemoveBackend(id string) error
}
type concreteBackendService struct {
s storage.Storage
}
func (cbs *concreteBackendService) ListBackends() ([]BackendConfig, error) {
connectors, err := cbs.s.ListConnectors()
if err != nil {
return nil, fmt.Errorf("failed to get connectors from storage: %w", err)
}
var res []BackendConfig
for _, c := range connectors {
// We know that this type is special, we don't want to use it at all here
if c.Type == connector.TypeRefuseAll {
continue
}
var b BackendConfig
if err := b.FromConnector(c); err != nil {
return res, err
}
res = append(res, b)
}
return res, nil
}
func (cbs *concreteBackendService) GetBackend(connectorID string) (BackendConfig, error) {
c, err := cbs.s.GetConnector(connectorID)
if err != nil {
return BackendConfig{}, fmt.Errorf("failed to get connector from storage: %w", err)
}
var res BackendConfig
if err := res.FromConnector(c); err != nil {
return BackendConfig{}, err
}
return res, nil
}
func (cbs *concreteBackendService) AddBackend(config BackendConfig) error {
storageConf, err := config.Storage()
if err != nil {
return fmt.Errorf("failed to create storage configuration: %w", err)
}
return cbs.s.CreateConnector(storageConf)
}
func (cbs *concreteBackendService) RemoveBackend(connectorID string) error {
return cbs.s.DeleteConnector(connectorID)
}
func New(s storage.Storage) Service {
return &concreteBackendService{s}
}

View file

@ -0,0 +1,215 @@
package backend_test
import (
"encoding/json"
"fmt"
"testing"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/connector"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/services/backend"
"github.com/dexidp/dex/storage"
"github.com/dexidp/dex/storage/memory"
logt "github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
testDomain string = "https://test.domain.com"
testClientID string = "this_is_an_id"
testClientSecret string = "this_is_a_secret"
testRedirectURI string = "http://127.0.0.1:5000/callback"
testConnectorName string = "Test connector"
)
func generateConnector(id string) storage.Connector {
confJson := fmt.Sprintf(`{
"issuer": "%s",
"clientID": "%s",
"clientSecret": "%s",
"redirectURI": "%s"
}`, testDomain, testClientID, testClientSecret, testRedirectURI)
storageConfig := storage.Connector{
ID: id,
Name: testConnectorName,
Type: "oidc",
Config: []byte(confJson),
}
return storageConfig
}
func generateConfig(id string) backend.BackendConfig {
return backend.BackendConfig{
ID: id,
Name: testConnectorName,
Issuer: testDomain,
ClientID: testClientID,
ClientSecret: testClientSecret,
RedirectURI: testRedirectURI,
}
}
func checkStrInMap(t *testing.T, vals map[string]interface{}, key, expected string) {
rawVal, ok := vals[key]
require.Truef(t, ok, "missing key %s", key)
strVal, ok := rawVal.(string)
require.Truef(t, ok, "invalid string format %v", rawVal)
assert.Equal(t, expected, strVal, "unexpected value")
}
func initStorage(t *testing.T) storage.Storage {
logger, _ := logt.NewNullLogger()
s := memory.New(logger)
require.NoError(t, s.CreateConnector(connector.RefuseAllConnectorConfig))
require.NoError(t, s.CreateConnector(generateConnector("test0")))
require.NoError(t, s.CreateConnector(generateConnector("test1")))
return s
}
func TestBackendConfigFromConnector(t *testing.T) {
connector := generateConnector("test")
var bc backend.BackendConfig
require.NoError(t, bc.FromConnector(connector))
assert.Equal(t, testDomain, bc.Issuer)
assert.Equal(t, testClientID, bc.ClientID)
assert.Equal(t, testClientSecret, bc.ClientSecret)
assert.Equal(t, testRedirectURI, bc.RedirectURI)
assert.Equal(t, testConnectorName, bc.Name)
assert.Equal(t, "test", bc.ID)
}
func TestBackendConfigInvalidType(t *testing.T) {
connector := generateConnector("test")
connector.Type = "test"
var bc backend.BackendConfig
assert.ErrorIs(t, bc.FromConnector(connector), backend.ErrUnsupportedType)
}
func TestBackendConfigInvalidOIDCConfig(t *testing.T) {
connector := generateConnector("test")
connector.Config = []byte("toto")
var bc backend.BackendConfig
assert.ErrorContains(t, bc.FromConnector(connector), "invalid OIDC config")
}
func TestOIDCConfigFromBackendConfig(t *testing.T) {
conf := generateConfig("test")
oidcConf := conf.OIDC()
assert.Equal(t, testDomain, oidcConf.Issuer)
assert.Equal(t, testClientID, oidcConf.ClientID)
assert.Equal(t, testClientSecret, oidcConf.ClientSecret)
assert.Equal(t, testRedirectURI, oidcConf.RedirectURI)
}
func TestConnectorConfigFromBackendConfig(t *testing.T) {
conf := generateConfig("test")
con, err := conf.Storage()
require.NoError(t, err)
// The OIDC config is stored as JSON data, we just want the raw keys here
var oidcConf map[string]interface{}
require.NoError(t, json.Unmarshal(con.Config, &oidcConf))
assert.Equal(t, "oidc", con.Type)
assert.Equal(t, "test", con.ID)
assert.Equal(t, testConnectorName, con.Name)
checkStrInMap(t, oidcConf, "issuer", testDomain)
checkStrInMap(t, oidcConf, "clientID", testClientID)
checkStrInMap(t, oidcConf, "clientSecret", testClientSecret)
checkStrInMap(t, oidcConf, "redirectURI", testRedirectURI)
}
func TestListBackendsEmpty(t *testing.T) {
logger, _ := logt.NewNullLogger()
s := memory.New(logger)
// add the default refuse all connector, it should not be visible in the list
require.NoError(t, s.CreateConnector(connector.RefuseAllConnectorConfig))
srv := backend.New(s)
backends, err := srv.ListBackends() // empty list, and no error
require.NoError(t, err)
require.Len(t, backends, 0)
}
func TestListBackendsNotEmpty(t *testing.T) {
s := initStorage(t)
srv := backend.New(s)
backends, err := srv.ListBackends() // empty list, and no error
expectedIds := []string{"test0", "test1"}
require.NoError(t, err)
assert.Len(t, backends, 2)
for _, c := range backends {
assert.Contains(t, expectedIds, c.ID)
}
}
func TestGetBackend(t *testing.T) {
s := initStorage(t)
srv := backend.New(s)
t.Run("OK", func(t *testing.T) {
conf, err := srv.GetBackend("test0")
require.NoError(t, err)
assert.Equal(t, testDomain, conf.Issuer)
assert.Equal(t, testClientID, conf.ClientID)
assert.Equal(t, testClientSecret, conf.ClientSecret)
assert.Equal(t, testRedirectURI, conf.RedirectURI)
assert.Equal(t, testConnectorName, conf.Name)
assert.Equal(t, "test0", conf.ID)
})
t.Run("Not exist", func(t *testing.T) {
_, err := srv.GetBackend("toto")
assert.ErrorIs(t, err, storage.ErrNotFound)
})
t.Run("Invalid type", func(t *testing.T) {
_, err := srv.GetBackend("null") // null has a RefuseAll type, which is unsupported here
assert.ErrorIs(t, err, backend.ErrUnsupportedType)
})
}
func TestAddBackend(t *testing.T) {
s := initStorage(t)
srv := backend.New(s)
t.Run("OK", func(t *testing.T) {
conf := generateConfig("test_add")
require.NoError(t, srv.AddBackend(conf))
var parsedConf backend.BackendConfig
storageConf, err := s.GetConnector("test_add")
require.NoError(t, err)
require.NoError(t, parsedConf.FromConnector(storageConf))
assert.Equal(t, conf, parsedConf)
})
t.Run("Already exists", func(t *testing.T) {
require.ErrorIs(t, srv.AddBackend(generateConfig("test0")), storage.ErrAlreadyExists)
})
}
func TestRemoveBackend(t *testing.T) {
s := initStorage(t)
srv := backend.New(s)
t.Run("OK", func(t *testing.T) {
require.NoError(t, srv.AddBackend(generateConfig("to_remove")))
_, err := s.GetConnector("to_remove")
require.NoError(t, err) // no error means it's present
require.NoError(t, srv.RemoveBackend("to_remove"))
_, err = s.GetConnector("to_remove")
assert.ErrorIs(t, err, storage.ErrNotFound) // means it's been deleted
})
t.Run("No present", func(t *testing.T) {
require.ErrorIs(t, srv.RemoveBackend("toto"), storage.ErrNotFound)
})
}

View file

@ -0,0 +1,29 @@
package services
import (
"encoding/json"
"fmt"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/config"
dex_server "github.com/dexidp/dex/server"
"github.com/dexidp/dex/storage"
)
func CreateConnector(backend *config.BackendConfig, dexConf *dex_server.Config, connectorIDs []string) error {
for _, id := range connectorIDs {
if id == backend.ID {
return nil
}
}
backendConfJson, err := json.Marshal(backend.Config)
if err != nil {
return fmt.Errorf("failed to serialize oidc config for backend %q: %s", backend.Name, err.Error())
}
return dexConf.Storage.CreateConnector(storage.Connector{
ID: backend.ID,
Name: backend.Name,
Type: string(backend.Type),
Config: backendConfJson,
})
}

View file

@ -0,0 +1,34 @@
package services
import (
"crypto/rand"
"encoding/hex"
"fmt"
)
// size in bytes of the client ids and secrets
const idSecretSize = 32
func generateRandomHex(size int) (string, error) {
raw := make([]byte, size)
n, err := rand.Read(raw)
if err != nil {
return "", fmt.Errorf("failed to read from random generator: %w", err)
}
if n != size {
return "", fmt.Errorf("failed to read from random generator (%d/%d)", n, size)
}
return hex.EncodeToString(raw), nil
}
func GenerateClientIDSecret() (string, string, error) {
clientID, err := generateRandomHex(idSecretSize)
if err != nil {
return "", "", fmt.Errorf("failed to generate client id: %w", err)
}
clientSecret, err := generateRandomHex(idSecretSize)
if err != nil {
return "", "", fmt.Errorf("failed to generate client secret: %w", err)
}
return clientID, clientSecret, nil
}

View file

@ -0,0 +1,40 @@
package services
import (
"errors"
"fmt"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/config"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/connector"
"git.faercol.me/faercol/polyculeconnect/polyculeconnect/logger"
"github.com/dexidp/dex/storage"
"github.com/dexidp/dex/storage/memory"
"github.com/dexidp/dex/storage/sql"
)
func InitStorage(conf *config.AppConfig) (storage.Storage, error) {
var storageType storage.Storage
var err error
switch conf.StorageType {
case "memory":
storageType = memory.New(logger.L)
case "sqlite":
sqlconfig := sql.SQLite3{
File: conf.StorageConfig.File,
}
storageType, err = sqlconfig.Open(logger.L)
if err != nil {
logger.L.Fatalf("Failed to initialize sqlite backend: %s", err.Error())
}
default:
return storageType, fmt.Errorf("unsupported storage backend type: %s", conf.StorageType)
}
return storageType, nil
}
func AddDefaultBackend(s storage.Storage) error {
if err := s.CreateConnector(connector.RefuseAllConnectorConfig); err != nil && !errors.Is(err, storage.ErrAlreadyExists) {
return fmt.Errorf("failed to add default backend: %w", err)
}
return nil
}

View file

@ -0,0 +1,98 @@
:root {
--crust: #dce0e8;
--mantle: #e6e9ef;
--base: #eff1f5;
--surface-0: #ccd0da;
--surface-1: #bcc0cc;
--surface-2: #acb0be;
--overlay-0: #9ca0b0;
--overlay-1: #8c8fa1;
--overlay-2: #7c7f93;
--subtext-0: #6c6f85;
--subtext-1: #5c5f77;
--text: #4c4f69;
--logo-purple: #340c46;
--logo-yellow: #fcbf00;
--logo-pink: #e50051;
--logo-blue: #009fe3;
}
body {
background-color: var(--base);
color: var(--text);
margin: 0;
}
.site-header {
width: 100%;
display: flex;
padding: 10px;
margin-bottom: 40px;
.site-logo img {
height: 100px;
}
}
.container {
background-color: var(--mantle);
display: grid;
grid-template-columns: 1;
row-gap: 20px;
padding: 15px 50px;
max-width: 50%;
width: fit-content;
margin: auto;
border: 1px solid var(--surface-0);
border-radius: 5px;
}
.container-content {
margin-bottom: 10px;
margin-top: 10px;
}
.form-elements {
display: grid;
grid-template-columns: 1;
row-gap: 10px;
margin-bottom: 15px;
padding: 0 10px;
}
.form-buttons {
display: flex;
justify-content: space-between;
}
.form-input {
width: 100%;
padding: 5px;
box-sizing: border-box;
}
.form-input::placeholder {
color: var(--subtext-1);
}
.button {
border: none;
color: var(--mantle);
padding: 5px 20px;
border-radius: 3px;
text-align: center;
vertical-align: middle;
text-decoration: none;
display: inline-block;
font-size: medium;
cursor: pointer;
}
.button-accept {
background-color: var(--logo-blue);
}
.button-cancel {
background-color: var(--logo-pink);
}

View file

@ -1,42 +1,34 @@
{{ template "header.html" . }}
<div class="theme-panel">
<h2 class="theme-heading">Grant Access</h2>
<hr class="dex-separator">
<div>
<div class="container">
<div class="container-content">
{{ if .Scopes }}
<div class="dex-subtle-text">{{ .Client }} would like to:</div>
<ul class="dex-list">
<div>{{ .Client }} would like to:</div>
<ul>
{{ range $scope := .Scopes }}
<li>{{ $scope }}</li>
{{ end }}
</ul>
{{ else }}
<div class="dex-subtle-text">{{ .Client }} has not requested any personal information</div>
<div>{{ .Client }} has not requested any personal information</div>
{{ end }}
</div>
<hr class="dex-separator">
<div>
<div class="theme-form-row">
<form method="post">
<input type="hidden" name="req" value="{{ .AuthReqID }}" />
<input type="hidden" name="approval" value="approve">
<button type="submit" class="dex-btn theme-btn--success">
<span class="dex-btn-text">Grant Access</span>
</button>
</form>
</div>
<div class="theme-form-row">
<form method="post">
<input type="hidden" name="req" value="{{ .AuthReqID }}" />
<input type="hidden" name="approval" value="rejected">
<button type="submit" class="dex-btn theme-btn-provider">
<span class="dex-btn-text">Cancel</span>
</button>
</form>
</div>
<div class="form-buttons">
<form method="post" class="container-form">
<input type="hidden" name="req" value="{{ .AuthReqID }}" />
<input type="hidden" name="approval" value="approve">
<button type="submit" class="button button-accept">
<span>Grant Access</span>
</button>
</form>
<form method="post" class="container-form">
<input type="hidden" name="req" value="{{ .AuthReqID }}" />
<input type="hidden" name="approval" value="rejected">
<button type="submit" class="button button-cancel">
<span>Cancel</span>
</button>
</form>
</div>
</div>

View file

@ -1,8 +1,10 @@
{{ template "header.html" . }}
<div class="theme-panel">
<h2 class="theme-heading">{{ .ErrType }}</h2>
<p>{{ .ErrMsg }}</p>
<div class="container">
<div class="container-content">
<h2>{{ .ErrType }}</h2>
<p>{{ .ErrMsg }}</p>
</div>
</div>
{{ template "footer.html" . }}

View file

@ -7,6 +7,8 @@
<title>PolyculeConnect</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/style/index.css">
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16x16.png">
@ -16,4 +18,11 @@
<meta name="theme-color" content="#ffffff">
</head>
<body>
<body>
<div class="site-header">
<div class="site-logo">
<img src="/static/img/logo-text.png" alt="PolyculeConnect website logo">
</div>
</div>

View file

@ -2,12 +2,22 @@
<script src="/static/scripts/index.js" defer></script>
<div>
<form action="" id="connectorform">
<label for="cname">Connector name</label>
<input type="text" id="cname" name="connector_id">
<input type="submit">
</form>
<div class="container">
<div class="container-content">
Enter the service to use for login.
</div>
<div class="container-content">
<form action="" id="connectorform" class="container-form">
<div class="form-elements">
<input type="text" id="cname" name="connector_id" placeholder="Service name" required
class="form-input">
</div>
<div class="form-buttons">
<input type="submit" class="button button-accept" value="Continue">
</div>
</form>
</div>
</div>
{{ template "footer.html" . }}