Repository: jkroepke/openvpn-auth-oauth2
Pull request #24 (2023-09-23 22:53:28)

Add more tests and fix openvpn.bypass is nil

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/24?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) Report
Patch coverage: **`60.62%`** and project coverage change: **`+11.97%`** :tada:
> Comparison is base [(`223dca4`)](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/commit/223dca44ff40c0487419ae42b6cce2f1542ce5c2?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) 42.05% compared to head [(`c287e5c`)](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/24?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) 54.02%.

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##             main      #24       +/-   ##
===========================================
+ Coverage   42.05%   54.02%   +11.97%     
===========================================
  Files           7        9        +2     
  Lines         585      720      +135     
===========================================
+ Hits          246      389      +143     
+ Misses        329      307       -22     
- Partials       10       24       +14     
```


| [Files Changed](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/24?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) | Coverage Δ | |
|---|---|---|
| [internal/config/config.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/24?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-aW50ZXJuYWwvY29uZmlnL2NvbmZpZy5nbw==) | `62.50% <0.00%> (-0.61%)` | :arrow_down: |
| [internal/oauth2/handler.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/24?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-aW50ZXJuYWwvb2F1dGgyL2hhbmRsZXIuZ28=) | `0.00% <0.00%> (ø)` | |
| [cmd/root.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/24?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-Y21kL3Jvb3QuZ28=) | `8.25% <5.71%> (ø)` | |
| [internal/oauth2/provider.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/24?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-aW50ZXJuYWwvb2F1dGgyL3Byb3ZpZGVyLmdv) | `73.62% <71.42%> (-2.14%)` | :arrow_down: |
| [internal/openvpn/client.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/24?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-aW50ZXJuYWwvb3BlbnZwbi9jbGllbnQuZ28=) | `63.04% <80.00%> (+63.04%)` | :arrow_up: |
| [internal/oauth2/providers/oidc/validate.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/24?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-aW50ZXJuYWwvb2F1dGgyL3Byb3ZpZGVycy9vaWRjL3ZhbGlkYXRlLmdv) | `83.33% <83.33%> (ø)` | |
| [internal/openvpn/client\_connection.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/24?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-aW50ZXJuYWwvb3BlbnZwbi9jbGllbnRfY29ubmVjdGlvbi5nbw==) | `100.00% <100.00%> (+6.38%)` | :arrow_up: |
| [internal/state/state.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/24?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-aW50ZXJuYWwvc3RhdGUvc3RhdGUuZ28=) | `80.00% <100.00%> (ø)` | |
| [internal/utils/slices.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/24?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-aW50ZXJuYWwvdXRpbHMvc2xpY2VzLmdv) | `100.00% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/24?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 483dee2..0a900be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [1.3.0] - 2023-09-24
+
+### Added
+
+- deb/rpm packages as binary release
+- Added systemd unit file
+- Added OAUTH2 provider construct for custom provider handling
+
+### Fixes
+
+- openvpn.bypass is nil
+
 ## [1.2.1] - 2023-09-16
 
 ### Changes
diff --git a/cmd/root.go b/cmd/root.go
index cfd74e8..49d2433 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -22,29 +22,32 @@ import (
 
 var k = koanf.New(".")
 
-func Execute(version, commit, date string) {
+func Execute(version, commit, date string) int {
 	logger, _ := zap.NewProduction()
 	defer logger.Sync() //nolint:errcheck
 
 	f := config.FlagSet()
 	if err := f.Parse(os.Args[1:]); err != nil {
-		logger.Fatal(fmt.Sprintf("error parsing cli args: %v", err))
+		logger.Error(fmt.Sprintf("error parsing cli args: %v", err))
+		return 1
 	}
 
 	if versionFlag, _ := f.GetBool("version"); versionFlag {
 		fmt.Printf("version: %s\ncommit: %s\ndate: %s\ngo: %s\n", version, commit, date, runtime.Version())
-		os.Exit(0)
+		return 0
 	}
 
-	configFile, _ := f.GetString("configfile")
+	configFile, _ := f.GetString("config")
 	if configFile != "" {
 		if err := k.Load(file.Provider(configFile), yaml.Parser()); err != nil {
-			logger.Fatal(fmt.Sprintf("error loading config: %v", err))
+			logger.Error(fmt.Sprintf("error loading config: %v", err))
+			return 1
 		}
 	}
 
 	if err := k.Load(posflag.Provider(f, ".", k), nil); err != nil {
-		logger.Fatal(fmt.Sprintf("error loading config: %v", err))
+		logger.Error(fmt.Sprintf("error loading config: %v", err))
+		return 1
 	}
 
 	if err := k.Load(env.ProviderWithValue("CONFIG_", ".", func(s string, v string) (string, interface{}) {
@@ -58,17 +61,20 @@ func Execute(version, commit, date string) {
 		// Otherwise, return the plain string.
 		return key, v
 	}), nil); err != nil {
-		logger.Fatal(fmt.Sprintf("error loading config: %v", err))
+		logger.Error(fmt.Sprintf("error loading config: %v", err))
+		return 1
 	}
 
 	var conf config.Config
 	if err := k.Unmarshal("", &conf); err != nil {
-		logger.Fatal(fmt.Sprintf("error loading config: %v", err))
+		logger.Error(fmt.Sprintf("error loading config: %v", err))
+		return 1
 	}
 
 	logger, err := configureLogger(&conf)
 	if err != nil {
-		logger.Fatal(fmt.Sprintf("error configure logger: %v", err))
+		logger.Error(fmt.Sprintf("error configure logger: %v", err))
+		return 1
 	}
 	defer logger.Sync() //nolint:errcheck
 
@@ -76,51 +82,63 @@ func Execute(version, commit, date string) {
 
 	if err := config.Validate(&conf); err != nil {
 		sl.Error(fmt.Sprintf("error validating config: %v", err))
-		os.Exit(1)
+		return 1
 	}
 
 	oidcClient, err := oauth2.NewProvider(sl, &conf)
 	if err != nil {
 		sl.Error(err.Error())
-		os.Exit(1)
+		return 1
 	}
 
-	oidcClient.OAuthConfig()
-
 	openvpnClient := openvpn.NewClient(sl, &conf)
+	done := make(chan int, 1)
 
 	go func() {
 		stdLogger, err := zap.NewStdLogAt(logger, zap.ErrorLevel)
 		if err != nil {
 			sl.Error(err.Error())
-			os.Exit(1)
+			done <- 1
 		}
 
 		server := &http.Server{
 			Addr:     conf.Http.Listen,
 			ErrorLog: stdLogger,
-			Handler:  oauth2.Handler(sl, &oidcClient, &conf, openvpnClient),
+			Handler:  oauth2.Handler(sl, oidcClient, &conf, openvpnClient),
 		}
 
 		if conf.Http.Tls {
 			sl.Info(fmt.Sprintf("HTTPS server listen on %s with base url %s", conf.Http.Listen, conf.Http.BaseUrl))
 			if err := server.ListenAndServeTLS(conf.Http.CertFile, conf.Http.KeyFile); err != nil {
 				sl.Error(err.Error())
-				os.Exit(1)
+				done <- 1
 			}
 		} else {
 			sl.Info(fmt.Sprintf("HTTP server listen on %s with base url %s", conf.Http.Listen, conf.Http.BaseUrl))
 			if err := server.ListenAndServe(); err != nil {
 				sl.Error(err.Error())
-				os.Exit(1)
+				done <- 1
 			}
 		}
 	}()
 
-	if err := openvpnClient.Connect(); err != nil {
-		sl.Error(err.Error())
-		os.Exit(1)
-	}
+	sigs := make(chan os.Signal, 1)
+	go func() {
+		sig := <-sigs
+		logger.Info(fmt.Sprintf("reciving signal: %s", sig.String()))
+		openvpnClient.Shutdown()
+	}()
+
+	go func() {
+		defer openvpnClient.Shutdown()
+		if err := openvpnClient.Connect(); err != nil {
+			sl.Error(err.Error())
+			done <- 1
+		}
+		done <- 0
+	}()
+
+	return <-done
 }
 
 func configureLogger(conf *config.Config) (*zap.Logger, error) {
diff --git a/go.mod b/go.mod
index f401451..d6d89b6 100644
--- a/go.mod
+++ b/go.mod
@@ -14,6 +14,7 @@ require (
 	go.uber.org/zap v1.26.0
 	go.uber.org/zap/exp v0.2.0
 	golang.org/x/oauth2 v0.12.0
+	golang.org/x/text v0.13.0
 )
 
 require (
@@ -40,7 +41,6 @@ require (
 	go.uber.org/multierr v1.11.0 // indirect
 	golang.org/x/crypto v0.13.0 // indirect
 	golang.org/x/sys v0.12.0 // indirect
-	golang.org/x/text v0.13.0 // indirect
 	google.golang.org/appengine v1.6.8 // indirect
 	google.golang.org/protobuf v1.31.0 // indirect
 	gopkg.in/square/go-jose.v2 v2.6.0 // indirect
diff --git a/internal/config/config.go b/internal/config/config.go
index 6ce0169..b111c57 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -48,6 +48,7 @@ type OpenVpnBypass struct {
 
 type OAuth2 struct {
 	Issuer    string           `koanf:"issuer"`
+	Provider  string           `koanf:"provider"`
 	Endpoints *OAuth2Endpoints `koanf:"endpoint"`
 	Client    *OAuth2Client    `koanf:"client"`
 	Scopes    []string         `koanf:"scopes"`
@@ -94,8 +95,9 @@ func FlagSet() *flag.FlagSet {
 	f.String("http.callback_template_path", "", "Path to a HTML file which is displayed at the end of the screen. (env: CONFIG_HTTP_CALLBACK_TEMPLATE_PATH)")
 	f.String("openvpn.addr", "tcp://127.0.0.1:54321", "openvpn management interface addr. (env: CONFIG_OPENVPN_ADDR)")
 	f.String("openvpn.password", "", "openvpn management interface password. (env: CONFIG_OPENVPN_PASSWORD)")
-	f.StringSlice("oauth2.bypass.cn", []string{}, "bypass oauth authentication for CNs. (env: CONFIG_OAUTH2_BYPASS_CN)")
+	f.StringSlice("openvpn.bypass.cn", []string{}, "bypass oauth authentication for CNs. (env: CONFIG_OAUTH2_BYPASS_CN)")
 	f.String("oauth2.issuer", "", "oauth2 issuer. (env: CONFIG_OAUTH2_ISSUER)")
+	f.String("oauth2.provider", "oidc", "oauth2 provider. (env: CONFIG_OAUTH2_PROVIDER)")
 	f.String("oauth2.endpoint.discovery", "", "custom oauth2 discovery url. (env: CONFIG_OAUTH2_ENDPOINT_DISCOVERY)")
 	f.String("oauth2.endpoint.auth", "", "custom oauth2 auth endpoint. (env: CONFIG_OAUTH2_ENDPOINT_AUTH)")
 	f.String("oauth2.endpoint.token", "", "custom oauth2 token endpoint. (env: CONFIG_OAUTH2_ENDPOINT_TOKEN)")
@@ -123,6 +125,7 @@ func Validate(conf *Config) error {
 			return fmt.Errorf("%s is nil", key)
 		}
 	}
+
 	for key, value := range map[string]any{
 		"oauth2.client":    conf.Oauth2.Client,
 		"oauth2.endpoints": conf.Oauth2.Endpoints,
diff --git a/internal/oauth2/handler.go b/internal/oauth2/handler.go
index f48fa93..0d0e39d 100644
--- a/internal/oauth2/handler.go
+++ b/internal/oauth2/handler.go
@@ -14,18 +14,18 @@ import (
 	"github.com/zitadel/oidc/v2/pkg/oidc"
 )
 
-func Handler(logger *slog.Logger, oidcClient *rp.RelyingParty, conf *config.Config, openvpnClient *openvpn.Client) *http.ServeMux {
+func Handler(logger *slog.Logger, oidcProvider *Provider, conf *config.Config, openvpnClient *openvpn.Client) *http.ServeMux {
 	baseUrl, _ := url.Parse(conf.Http.BaseUrl)
 
 	mux := http.NewServeMux()
 	mux.Handle("/", http.NotFoundHandler())
-	mux.Handle(strings.TrimSuffix(baseUrl.Path, "/")+"/oauth2/start", oauth2Start(logger, oidcClient, conf))
-	mux.Handle(strings.TrimSuffix(baseUrl.Path, "/")+"/oauth2/callback", oauth2Callback(logger, oidcClient, conf, openvpnClient))
+	mux.Handle(strings.TrimSuffix(baseUrl.Path, "/")+"/oauth2/start", oauth2Start(logger, oidcProvider, conf))
+	mux.Handle(strings.TrimSuffix(baseUrl.Path, "/")+"/oauth2/callback", oauth2Callback(logger, oidcProvider, conf, openvpnClient))
 
 	return mux
 }
 
-func oauth2Start(logger *slog.Logger, oidcClient *rp.RelyingParty, conf *config.Config) http.Handler {
+func oauth2Start(logger *slog.Logger, oidcProvider *Provider, conf *config.Config) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		sessionState := r.URL.Query().Get("state")
 		if sessionState == "" {
@@ -48,12 +48,11 @@ func oauth2Start(logger *slog.Logger, oidcClient *rp.RelyingParty, conf *config.
 
 		rp.AuthURLHandler(func() string {
 			return sessionState
-		}, *oidcClient).ServeHTTP(w, r)
+		}, oidcProvider.RelyingParty).ServeHTTP(w, r)
 	})
 }
 
-func oauth2Callback(logger *slog.Logger, oidcClient *rp.RelyingParty, conf *config.Config, openvpnClient *openvpn.Client) http.Handler {
-
+func oauth2Callback(logger *slog.Logger, oidcProvider *Provider, conf *config.Config, openvpnClient *openvpn.Client) http.Handler {
 	return rp.CodeExchangeHandler(func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], encryptedState string, rp rp.RelyingParty) {
 		session := state.NewEncoded(encryptedState)
 		if err := session.Decode(conf.Http.Secret); err != nil {
@@ -65,7 +64,7 @@ func oauth2Callback(logger *slog.Logger, oidcClient *rp.RelyingParty, conf *conf
 			return
 		}
 
-		if err := validateToken(conf, session, tokens); err != nil {
+		if err := oidcProvider.TokenValidator.Validate(session, tokens); err != nil {
 			logger.Warn(err.Error(),
 				"subject", tokens.IDTokenClaims.Subject,
 				"preferred_username", tokens.IDTokenClaims.PreferredUsername,
@@ -109,5 +108,5 @@ func oauth2Callback(logger *slog.Logger, oidcClient *rp.RelyingParty, conf *conf
 			logger.Error("executing template:", err)
 			w.WriteHeader(http.StatusInternalServerError)
 		}
-	}, *oidcClient)
+	}, oidcProvider.RelyingParty)
 }
diff --git a/internal/oauth2/provider.go b/internal/oauth2/provider.go
index ef272f8..9e86a5e 100644
--- a/internal/oauth2/provider.go
+++ b/internal/oauth2/provider.go
@@ -8,18 +8,35 @@ import (
 	"strings"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/providers/oidc"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
 	"github.com/zitadel/oidc/v2/pkg/client/rp"
 	httphelper "github.com/zitadel/oidc/v2/pkg/http"
+	token "github.com/zitadel/oidc/v2/pkg/oidc"
 	"golang.org/x/oauth2"
 )
 
+type Provider struct {
+	rp.RelyingParty
+	TokenValidator
+}
+
+type TokenValidator interface {
+	Validate(session *state.State, tokens *token.Tokens[*token.IDTokenClaims]) error
+}
+
 // NewProvider returns a [rp.RelyingParty] instance
-func NewProvider(logger *slog.Logger, conf *config.Config) (rp.RelyingParty, error) {
+func NewProvider(logger *slog.Logger, conf *config.Config) (*Provider, error) {
 	baseUrl, err := url.Parse(conf.Http.BaseUrl)
 	if err != nil {
 		return nil, fmt.Errorf("http.baseurl: %v", err)
 	}
 
+	tokenValidator, err := NewTokenValidateProvider(conf)
+	if err != nil {
+		return nil, err
+	}
+
 	redirectURI := fmt.Sprintf("%s/oauth2/callback", strings.TrimSuffix(conf.Http.BaseUrl, "/"))
 
 	cookieKey := []byte(conf.Http.Secret)
@@ -66,7 +83,7 @@ func NewProvider(logger *slog.Logger, conf *config.Config) (rp.RelyingParty, err
 			logger.Info(fmt.Sprintf("discover OIDC auto configuration for issuer %s", conf.Oauth2.Issuer))
 		}
 
-		return rp.NewRelyingPartyOIDC(
+		relayingParty, err := rp.NewRelyingPartyOIDC(
 			conf.Oauth2.Issuer,
 			conf.Oauth2.Client.Id,
 			conf.Oauth2.Client.Secret,
@@ -74,6 +91,15 @@ func NewProvider(logger *slog.Logger, conf *config.Config) (rp.RelyingParty, err
 			conf.Oauth2.Scopes,
 			options...,
 		)
+
+		if err != nil {
+			return nil, err
+		}
+
+		return &Provider{
+			RelyingParty:   relayingParty,
+			TokenValidator: tokenValidator,
+		}, nil
 	}
 
 	logger.Info(fmt.Sprintf("manually configure oauth2 provider with endpoints %s and %s", conf.Oauth2.Endpoints.Auth, conf.Oauth2.Endpoints.Token))
@@ -89,5 +115,22 @@ func NewProvider(logger *slog.Logger, conf *config.Config) (rp.RelyingParty, err
 		},
 	}
 
-	return rp.NewRelyingPartyOAuth(rpConfig, options...)
+	relayingParty, err := rp.NewRelyingPartyOAuth(rpConfig, options...)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Provider{
+		RelyingParty:   relayingParty,
+		TokenValidator: tokenValidator,
+	}, nil
+}
+
+func NewTokenValidateProvider(conf *config.Config) (TokenValidator, error) {
+	switch conf.Oauth2.Provider {
+	case "oidc":
+		return oidc.NewProvider(conf), nil
+	default:
+		return nil, fmt.Errorf("unknown oauth2 provider: %s", conf.Oauth2.Provider)
+	}
 }
diff --git a/internal/oauth2/providers/oidc/validate.go b/internal/oauth2/providers/oidc/validate.go
new file mode 100644
index 0000000..e838cef
--- /dev/null
+++ b/internal/oauth2/providers/oidc/validate.go
@@ -0,0 +1,113 @@
+package oidc
+
+import (
+	"errors"
+	"fmt"
+	"slices"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
+	"github.com/zitadel/oidc/v2/pkg/oidc"
+)
+
+type Provider struct {
+	conf *config.Config
+}
+
+func NewProvider(conf *config.Config) *Provider {
+	return &Provider{
+		conf: conf,
+	}
+}
+
+func (p *Provider) Validate(session *state.State, tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
+	if err := p.ValidateGroups(tokens); err != nil {
+		return err
+	}
+	if err := p.ValidateRoles(tokens); err != nil {
+		return err
+	}
+	if err := p.ValidateCommonName(session, tokens); err != nil {
+		return err
+	}
+	if err := p.ValidateIpAddr(session, tokens); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (p *Provider) ValidateGroups(tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
+	if len(p.conf.Oauth2.Validate.Groups) == 0 {
+		return nil
+	}
+
+	tokenGroups, ok := tokens.IDTokenClaims.Claims["groups"]
+	if !ok {
+		return errors.New("missing groups claim")
+	}
+
+	tokenGroupsList := utils.CastToSlice[string](tokenGroups)
+
+	for _, group := range p.conf.Oauth2.Validate.Groups {
+		if !slices.Contains(tokenGroupsList, group) {
+			return fmt.Errorf("missing required group %s", group)
+		}
+	}
+
+	return nil
+}
+
+func (p *Provider) ValidateRoles(tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
+	if len(p.conf.Oauth2.Validate.Roles) == 0 {
+		return nil
+	}
+
+	tokenRoles, ok := tokens.IDTokenClaims.Claims["roles"]
+	if !ok {
+		return errors.New("missing roles claim")
+	}
+
+	tokenRolesList := utils.CastToSlice[string](tokenRoles)
+
+	for _, role := range p.conf.Oauth2.Validate.Roles {
+		if !slices.Contains(tokenRolesList, role) {
+			return fmt.Errorf("missing required role %s", role)
+		}
+	}
+
+	return nil
+}
+func (p *Provider) ValidateCommonName(session *state.State, tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
+	if p.conf.Oauth2.Validate.CommonName == "" {
+		return nil
+	}
+
+	tokenCommonName, ok := tokens.IDTokenClaims.Claims[p.conf.Oauth2.Validate.CommonName]
+	if !ok {
+		return fmt.Errorf("missing %s claim", p.conf.Oauth2.Validate.CommonName)
+	}
+
+	if tokenCommonName != session.CommonName {
+		return fmt.Errorf("common_name mismatch: openvpn client: %s - oidc token: %s", tokenCommonName, session.CommonName)
+	}
+
+	return nil
+}
+func (p *Provider) ValidateIpAddr(session *state.State, tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
+	if !p.conf.Oauth2.Validate.IpAddr {
+		return nil
+	}
+
+	tokenIpaddr, ok := tokens.IDTokenClaims.Claims["ipaddr"]
+	if !ok {
+		return errors.New("missing ipaddr claim")
+	}
+
+	if tokenIpaddr != session.Ipaddr {
+		return fmt.Errorf("ipaddr mismatch: openvpn client: %s - oidc token: %s", tokenIpaddr, session.Ipaddr)
+	}
+
+	return nil
+}
diff --git a/internal/oauth2/validate.go b/internal/oauth2/validate.go
deleted file mode 100644
index eb5f37b..0000000
--- a/internal/oauth2/validate.go
+++ /dev/null
@@ -1,111 +0,0 @@
-package oauth2
-
-import (
-	"errors"
-	"fmt"
-	"slices"
-
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
-	"github.com/zitadel/oidc/v2/pkg/oidc"
-)
-
-func validateToken(conf *config.Config, session *state.State, tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
-	if err := validateGroups(conf, tokens); err != nil {
-		return err
-	}
-	if err := validateRoles(conf, tokens); err != nil {
-		return err
-	}
-	if err := validateCommonName(conf, session, tokens); err != nil {
-		return err
-	}
-	if err := validateIpAddr(conf, session, tokens); err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func validateGroups(conf *config.Config, tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
-	if len(conf.Oauth2.Validate.Groups) == 0 {
-		return nil
-	}
-
-	tokenGroups, ok := tokens.IDTokenClaims.Claims["groups"]
-	if !ok {
-		return errors.New("missing groups claim")
-	}
-
-	tokenGroupsList := castToSlice[string](tokenGroups)
-
-	for _, group := range conf.Oauth2.Validate.Groups {
-		if !slices.Contains(tokenGroupsList, group) {
-			return fmt.Errorf("missing required group %s", group)
-		}
-	}
-
-	return nil
-}
-
-func validateRoles(conf *config.Config, tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
-	if len(conf.Oauth2.Validate.Roles) == 0 {
-		return nil
-	}
-
-	tokenRoles, ok := tokens.IDTokenClaims.Claims["roles"]
-	if !ok {
-		return errors.New("missing roles claim")
-	}
-
-	tokenRolesList := castToSlice[string](tokenRoles)
-
-	for _, role := range conf.Oauth2.Validate.Roles {
-		if !slices.Contains(tokenRolesList, role) {
-			return fmt.Errorf("missing required role %s", role)
-		}
-	}
-
-	return nil
-}
-func validateCommonName(conf *config.Config, session *state.State, tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
-	if conf.Oauth2.Validate.CommonName == "" {
-		return nil
-	}
-
-	tokenCommonName, ok := tokens.IDTokenClaims.Claims[conf.Oauth2.Validate.CommonName]
-	if !ok {
-		return fmt.Errorf("missing %s claim", conf.Oauth2.Validate.CommonName)
-	}
-
-	if tokenCommonName != session.CommonName {
-		return fmt.Errorf("common_name mismatch: openvpn client: %s - oidc token: %s", tokenCommonName, session.CommonName)
-	}
-
-	return nil
-}
-func validateIpAddr(conf *config.Config, session *state.State, tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
-	if !conf.Oauth2.Validate.IpAddr {
-		return nil
-	}
-
-	tokenIpaddr, ok := tokens.IDTokenClaims.Claims["ipaddr"]
-	if !ok {
-		return errors.New("missing ipaddr claim")
-	}
-
-	if tokenIpaddr != session.Ipaddr {
-		return fmt.Errorf("ipaddr mismatch: openvpn client: %s - oidc token: %s", tokenIpaddr, session.Ipaddr)
-	}
-
-	return nil
-}
-
-func castToSlice[T any](tokenGroups any) []T {
-	tokenGroupsInterfaceList := tokenGroups.([]any)
-	tokenGroupsList := make([]T, len(tokenGroupsInterfaceList))
-	for i, v := range tokenGroupsInterfaceList {
-		tokenGroupsList[i] = v.(T)
-	}
-	return tokenGroupsList
-}
diff --git a/internal/openvpn/client.go b/internal/openvpn/client.go
index 747c996..5288626 100644
--- a/internal/openvpn/client.go
+++ b/internal/openvpn/client.go
@@ -152,7 +152,7 @@ func (c *Client) processClient(client *ClientConnection) error {
 			"username", client.Env["username"],
 		)
 
-		if slices.Contains(c.conf.OpenVpn.Bypass.CommonNames, client.Env["common_name"]) {
+		if val, ok := client.Env["common_name"]; ok && slices.Contains(c.conf.OpenVpn.Bypass.CommonNames, val) {
 			c.logger.Info("client bypass authentication",
 				"cid", client.Cid,
 				"kid", client.Kid,
@@ -183,7 +183,7 @@ func (c *Client) processClient(client *ClientConnection) error {
 			return err
 		}
 
-		startUrl := fmt.Sprintf("%s/oauth2/start?state=%s", c.conf.Http.BaseUrl, url.QueryEscape(session.Encoded))
+		startUrl := fmt.Sprintf("%s/oauth2/start?state=%s", strings.TrimSuffix(c.conf.Http.BaseUrl, "/"), url.QueryEscape(session.Encoded))
 		c.logger.Info("start pending auth",
 			"cid", client.Cid,
 			"kid", client.Kid,
@@ -193,14 +193,14 @@ func (c *Client) processClient(client *ClientConnection) error {
 		)
 		c.SendCommand(`client-pending-auth %d %d "WEB_AUTH::%s" %d`, client.Cid, client.Kid, startUrl, 600)
 	case "ESTABLISHED":
-		c.logger.Warn("client established",
+		c.logger.Info("client established",
 			"cid", client.Cid,
 			"reason", client.Reason,
 			"common_name", client.Env["common_name"],
 			"username", client.Env["username"],
 		)
 	case "DISCONNECT":
-		c.logger.Warn("client disconnected",
+		c.logger.Info("client disconnected",
 			"cid", client.Cid,
 			"reason", client.Reason,
 			"common_name", client.Env["common_name"],
@@ -212,6 +212,12 @@ func (c *Client) processClient(client *ClientConnection) error {
 	return nil
 }
 
+// Shutdown shutdowns the client connection
+func (c *Client) Shutdown() {
+	c.logger.Info("shutdown connection")
+	c.shutdownCh <- struct{}{}
+}
+
 // SendCommand passes command to a given connection (adds logging and EOL character) and returns the response
 func (c *Client) SendCommand(format string, a ...any) string {
 	c.commandsCh <- fmt.Sprintf(format, a...)
diff --git a/internal/openvpn/client_connection.go b/internal/openvpn/client_connection.go
index d18298b..4f876bf 100644
--- a/internal/openvpn/client_connection.go
+++ b/internal/openvpn/client_connection.go
@@ -8,18 +8,15 @@ import (
 )
 
 type ClientConnection struct {
-	Kid    int
-	Cid    int
+	Kid    uint64
+	Cid    uint64
 	Reason string
 	Env    map[string]string
 }
 
 func NewClientConnection(message string) (*ClientConnection, error) {
 	clientConnection := &ClientConnection{
-		Kid:    0,
-		Cid:    0,
-		Reason: "",
-		Env:    map[string]string{},
+		Env: map[string]string{},
 	}
 
 	for _, line := range strings.Split(strings.TrimSpace(message), "\r\n") {
@@ -35,14 +32,14 @@ func NewClientConnection(message string) (*ClientConnection, error) {
 
 			clientConnection.Reason = strings.Replace(clientInfo[0], ">CLIENT:", "", 1)
 
-			if cid, err := strconv.Atoi(clientInfo[1]); err != nil {
+			if cid, err := strconv.ParseUint(clientInfo[1], 10, 64); err != nil {
 				return nil, err
 			} else {
 				clientConnection.Cid = cid
 			}
 
 			if clientConnection.Reason != "DISCONNECT" && clientConnection.Reason != "ESTABLISHED" {
-				if kid, err := strconv.Atoi(clientInfo[2]); err != nil {
+				if kid, err := strconv.ParseUint(clientInfo[2], 10, 64); err != nil {
 					return nil, err
 				} else {
 					clientConnection.Kid = kid
@@ -64,9 +61,5 @@ func NewClientConnection(message string) (*ClientConnection, error) {
 		return nil, errors.New("unable to parse client reason")
 	}
 
-	if clientConnection.Cid == 0 {
-		return nil, errors.New("unable to parse CID")
-	}
-
 	return clientConnection, nil
 }
diff --git a/internal/state/state.go b/internal/state/state.go
index 99ead92..b1f3162 100644
--- a/internal/state/state.go
+++ b/internal/state/state.go
@@ -8,15 +8,15 @@ import (
 )
 
 type State struct {
-	Cid        int    `json:"cid"`
-	Kid        int    `json:"kid"`
+	Cid        uint64 `json:"cid"`
+	Kid        uint64 `json:"kid"`
 	Ipaddr     string `json:"ipaddr"`
 	CommonName string `json:"common_name"`
 
 	Encoded string
 }
 
-func New(cid, kid int, ipaddr, commonName string) *State {
+func New(cid, kid uint64, ipaddr, commonName string) *State {
 	return &State{
 		Cid:        cid,
 		Kid:        kid,
diff --git a/internal/utils/slices.go b/internal/utils/slices.go
new file mode 100644
index 0000000..a89249c
--- /dev/null
+++ b/internal/utils/slices.go
@@ -0,0 +1,10 @@
+package utils
+
+func CastToSlice[T any](tokenGroups any) []T {
+	tokenGroupsInterfaceList := tokenGroups.([]any)
+	tokenGroupsList := make([]T, len(tokenGroupsInterfaceList))
+	for i, v := range tokenGroupsInterfaceList {
+		tokenGroupsList[i] = v.(T)
+	}
+	return tokenGroupsList
+}
diff --git a/main.go b/main.go
index db7cdf5..eee2f13 100644
--- a/main.go
+++ b/main.go
@@ -1,6 +1,10 @@
 package main
 
-import "github.com/jkroepke/openvpn-auth-oauth2/cmd"
+import (
+	"os"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/cmd"
+)
 
 var (
 	version = "dev"
@@ -9,5 +13,5 @@ var (
 )
 
 func main() {
-	cmd.Execute(version, commit, date)
+	os.Exit(cmd.Execute(version, commit, date))
 }


Test patch:
diff --git a/cmd/root_test.go b/cmd/root_test.go
new file mode 100644
index 0000000..bd396f7
--- /dev/null
+++ b/cmd/root_test.go
@@ -0,0 +1,20 @@
+package cmd
+
+import (
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestExecuteVersion(t *testing.T) {
+	os.Args = []string{"openvpn-auth-oauth2", "--version"}
+
+	returnCode := Execute("version", "commit", "date")
+	assert.Equal(t, returnCode, 0)
+	// Output:
+	//version: version
+	//commit: commit
+	//date: date
+	//go: go1.21.1
+}
diff --git a/internal/oauth2/provider_test.go b/internal/oauth2/provider_test.go
index 29a145b..01de3da 100644
--- a/internal/oauth2/provider_test.go
+++ b/internal/oauth2/provider_test.go
@@ -48,6 +48,7 @@ func TestNewProvider(t *testing.T) {
 				Http: &config.Http{BaseUrl: "http://localhost/"},
 				Oauth2: &config.OAuth2{
 					Issuer:    svr.URL,
+					Provider:  "oidc",
 					Client:    &config.OAuth2Client{Id: "ID", Secret: "ID"},
 					Endpoints: &config.OAuth2Endpoints{},
 				},
@@ -59,8 +60,9 @@ func TestNewProvider(t *testing.T) {
 			&config.Config{
 				Http: &config.Http{BaseUrl: "http://localhost/"},
 				Oauth2: &config.OAuth2{
-					Issuer: svr.URL,
-					Client: &config.OAuth2Client{Id: "ID", Secret: "ID"},
+					Issuer:   svr.URL,
+					Provider: "oidc",
+					Client:   &config.OAuth2Client{Id: "ID", Secret: "ID"},
 					Endpoints: &config.OAuth2Endpoints{
 						Discovery: svr.URL + "/.well-known/openid-configuration",
 					},
@@ -73,8 +75,9 @@ func TestNewProvider(t *testing.T) {
 			&config.Config{
 				Http: &config.Http{BaseUrl: "http://localhost/"},
 				Oauth2: &config.OAuth2{
-					Issuer: svr.URL,
-					Client: &config.OAuth2Client{Id: "ID", Secret: "ID"},
+					Issuer:   svr.URL,
+					Provider: "oidc",
+					Client:   &config.OAuth2Client{Id: "ID", Secret: "ID"},
 					Endpoints: &config.OAuth2Endpoints{
 						Discovery: svr.URL + "/.well-known/openid-config",
 					},
@@ -87,8 +90,9 @@ func TestNewProvider(t *testing.T) {
 			&config.Config{
 				Http: &config.Http{BaseUrl: "http://localhost/"},
 				Oauth2: &config.OAuth2{
-					Issuer: svr.URL,
-					Client: &config.OAuth2Client{Id: "ID", Secret: "ID"},
+					Issuer:   svr.URL,
+					Provider: "oidc",
+					Client:   &config.OAuth2Client{Id: "ID", Secret: "ID"},
 					Endpoints: &config.OAuth2Endpoints{
 						Discovery: svr.URL + "/.well-known/openid-config",
 						Auth:      svr.URL + "/.well-known/authorize",
@@ -103,8 +107,9 @@ func TestNewProvider(t *testing.T) {
 			&config.Config{
 				Http: &config.Http{BaseUrl: "http://-"},
 				Oauth2: &config.OAuth2{
-					Issuer: svr.URL,
-					Client: &config.OAuth2Client{Id: "ID", Secret: "ID"},
+					Issuer:   svr.URL,
+					Provider: "oidc",
+					Client:   &config.OAuth2Client{Id: "ID", Secret: "ID"},
 					Endpoints: &config.OAuth2Endpoints{
 						Discovery: svr.URL + "/.well-known/openid-config",
 						Auth:      svr.URL + "/.well-known/authorize",
@@ -119,9 +124,10 @@ func TestNewProvider(t *testing.T) {
 			&config.Config{
 				Http: &config.Http{BaseUrl: "http://localhost/"},
 				Oauth2: &config.OAuth2{
-					Issuer: svr.URL,
-					Pkce:   true,
-					Client: &config.OAuth2Client{Id: "ID", Secret: "ID"},
+					Issuer:   svr.URL,
+					Provider: "oidc",
+					Pkce:     true,
+					Client:   &config.OAuth2Client{Id: "ID", Secret: "ID"},
 					Endpoints: &config.OAuth2Endpoints{
 						Discovery: svr.URL + "/.well-known/openid-config",
 						Auth:      svr.URL + "/.well-known/authorize",
diff --git a/internal/oauth2/validate_test.go b/internal/oauth2/providers/oidc/validate_test.go
similarity index 94%
rename from internal/oauth2/validate_test.go
rename to internal/oauth2/providers/oidc/validate_test.go
index ac3734c..eb498e6 100644
--- a/internal/oauth2/validate_test.go
+++ b/internal/oauth2/providers/oidc/validate_test.go
@@ -1,4 +1,4 @@
-package oauth2
+package oidc
 
 import (
 	"testing"
@@ -23,9 +23,10 @@ func TestValidateToken(t *testing.T) {
 		},
 	}
 
-	err := validateToken(conf, &state.State{}, token)
+	err := NewProvider(conf).Validate(&state.State{}, token)
 	assert.NoError(t, err)
 }
+
 func TestValidateGroups(t *testing.T) {
 	for _, tt := range []struct {
 		name           string
@@ -60,7 +61,8 @@ func TestValidateGroups(t *testing.T) {
 				},
 			}
 
-			err := validateGroups(conf, token)
+			err := NewProvider(conf).ValidateGroups(token)
+
 			if tt.err == "" {
 				assert.NoError(t, err)
 			} else {
@@ -104,7 +106,7 @@ func TestValidateRoles(t *testing.T) {
 				},
 			}
 
-			err := validateRoles(conf, token)
+			err := NewProvider(conf).ValidateRoles(token)
 			if tt.err == "" {
 				assert.NoError(t, err)
 			} else {
@@ -149,7 +151,7 @@ func TestValidateCommonName(t *testing.T) {
 				CommonName: tt.requiredCommonName,
 			}
 
-			err := validateCommonName(conf, session, token)
+			err := NewProvider(conf).ValidateCommonName(session, token)
 			if tt.err == "" {
 				assert.NoError(t, err)
 			} else {
@@ -195,7 +197,7 @@ func TestValidateIpAddr(t *testing.T) {
 				Ipaddr: tt.requiredIpAddr,
 			}
 
-			err := validateIpAddr(conf, session, token)
+			err := NewProvider(conf).ValidateIpAddr(session, token)
 			if tt.err == "" {
 				assert.NoError(t, err)
 			} else {
diff --git a/internal/openvpn/client_connection_test.go b/internal/openvpn/client_connection_test.go
index 4fca329..93a5757 100644
--- a/internal/openvpn/client_connection_test.go
+++ b/internal/openvpn/client_connection_test.go
@@ -14,9 +14,9 @@ func TestNewClientConnection(t *testing.T) {
 		clientConnection *ClientConnection
 		err              string
 	}{
-		{"client CONNECT", []string{">CLIENT:CONNECT,1,2", ">CLIENT:ENV,name1=val1", ">CLIENT:ENV,name2=", ">CLIENT:ENV,END"}, &ClientConnection{Cid: 1, Kid: 2, Reason: "CONNECT", Env: map[string]string{"name1": "val1", "name2": ""}}, ""},
-		{"client CONNECT invalid cid", []string{">CLIENT:CONNECT,k,2", ">CLIENT:ENV,name1=val1", ">CLIENT:ENV,name2=", ">CLIENT:ENV,END"}, &ClientConnection{Cid: 1, Kid: 2, Reason: "CONNECT", Env: map[string]string{"name1": "val1", "name2": ""}}, "strconv.Atoi: parsing \"k\": invalid syntax"},
-		{"client CONNECT invalid kid", []string{">CLIENT:CONNECT,1,k", ">CLIENT:ENV,name1=val1", ">CLIENT:ENV,name2=", ">CLIENT:ENV,END"}, &ClientConnection{Cid: 1, Kid: 2, Reason: "CONNECT", Env: map[string]string{"name1": "val1", "name2": ""}}, "strconv.Atoi: parsing \"k\": invalid syntax"},
+		{"client CONNECT", []string{">CLIENT:CONNECT,0,1", ">CLIENT:ENV,name1=val1", ">CLIENT:ENV,name2=", ">CLIENT:ENV,END"}, &ClientConnection{Cid: 0, Kid: 1, Reason: "CONNECT", Env: map[string]string{"name1": "val1", "name2": ""}}, ""},
+		{"client CONNECT invalid cid", []string{">CLIENT:CONNECT,k,2", ">CLIENT:ENV,name1=val1", ">CLIENT:ENV,name2=", ">CLIENT:ENV,END"}, &ClientConnection{Cid: 1, Kid: 2, Reason: "CONNECT", Env: map[string]string{"name1": "val1", "name2": ""}}, "strconv.ParseUint: parsing \"k\": invalid syntax"},
+		{"client CONNECT invalid kid", []string{">CLIENT:CONNECT,1,k", ">CLIENT:ENV,name1=val1", ">CLIENT:ENV,name2=", ">CLIENT:ENV,END"}, &ClientConnection{Cid: 1, Kid: 2, Reason: "CONNECT", Env: map[string]string{"name1": "val1", "name2": ""}}, "strconv.ParseUint: parsing \"k\": invalid syntax"},
 		{"client REAUTH", []string{">CLIENT:REAUTH,1,2", ">CLIENT:ENV,name1=val1", ">CLIENT:ENV,name2=", ">CLIENT:ENV,END"}, &ClientConnection{Cid: 1, Kid: 2, Reason: "REAUTH", Env: map[string]string{"name1": "val1", "name2": ""}}, ""},
 		{"client ESTABLISHED", []string{">CLIENT:ESTABLISHED,1", ">CLIENT:ENV,name1=val1", ">CLIENT:ENV,name2=", ">CLIENT:ENV,END"}, &ClientConnection{Cid: 1, Kid: 0, Reason: "ESTABLISHED", Env: map[string]string{"name1": "val1", "name2": ""}}, ""},
 		{"client DISCONNECT", []string{">CLIENT:DISCONNECT,1", ">CLIENT:ENV,name1=val1", ">CLIENT:ENV,name2=", ">CLIENT:ENV,END"}, &ClientConnection{Cid: 1, Kid: 0, Reason: "DISCONNECT", Env: map[string]string{"name1": "val1", "name2": ""}}, ""},
diff --git a/internal/openvpn/client_test.go b/internal/openvpn/client_test.go
new file mode 100644
index 0000000..bb185a9
--- /dev/null
+++ b/internal/openvpn/client_test.go
@@ -0,0 +1,93 @@
+package openvpn
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"log/slog"
+	"net"
+	"regexp"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestClient(t *testing.T) {
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+	l, err := net.Listen("tcp", "127.0.0.1:0")
+	assert.NoError(t, err)
+	defer l.Close()
+
+	conf := &config.Config{
+		Http: &config.Http{
+			BaseUrl: "http://localhost/",
+			Secret:  "0123456789101112",
+		},
+		OpenVpn: &config.OpenVpn{
+			Addr:   fmt.Sprintf("%s://%s", l.Addr().Network(), l.Addr().String()),
+			Bypass: &config.OpenVpnBypass{CommonNames: make([]string, 0)},
+		},
+	}
+
+	client := NewClient(logger, conf)
+	wg := sync.WaitGroup{}
+	wg.Add(1)
+
+	go func() {
+		conn, err := l.Accept()
+		assert.NoError(t, err)
+
+		defer conn.Close() //nolint:errcheck
+
+		reader := bufio.NewReader(conn)
+
+		sendLine(t, conn, ">INFO:OpenVPN Management Interface Version 5 -- type 'help' for more info:\r\n")
+		assert.Equal(t, "hold release", readLine(t, reader))
+		sendLine(t, conn, "SUCCESS: hold release succeeded\r\n")
+		assert.Equal(t, "version", readLine(t, reader))
+
+		sendLine(t, conn, "OpenVPN Version: OpenVPN Mock\r\nEND\r\n")
+		sendLine(t, conn, ">CLIENT:CONNECT,0,1\r\n>CLIENT:ENV,daemon=0\r\n>CLIENT:ENV,END\r\n")
+		assert.Equal(t, "client-deny 0 1 \"OpenVPN Client does not support SSO authentication via webauth\" \"OpenVPN Client does not support SSO authentication via webauth\"", readLine(t, reader))
+		sendLine(t, conn, "SUCCESS: client-deny command succeeded\r\n")
+		sendLine(t, conn, ">CLIENT:DISCONNECT,0\r\n>CLIENT:ENV,END\r\n")
+
+		sendLine(t, conn, ">CLIENT:CONNECT,1,2\r\n>CLIENT:ENV,untrusted_ip=127.0.0.1\r\n>CLIENT:ENV,common_name=test\r\n>CLIENT:ENV,IV_SSO=webauth\r\n>CLIENT:ENV,END\r\n")
+		pendingAuth := readLine(t, reader)
+		assert.Contains(t, pendingAuth, "client-pending-auth 1 2 \"WEB_AUTH::")
+
+		matches := regexp.MustCompile(`state=(.+)"`).FindStringSubmatch(pendingAuth)
+		assert.Len(t, matches, 2)
+
+		sessionState := state.NewEncoded(matches[1])
+		err = sessionState.Decode(conf.Http.Secret)
+		assert.NoError(t, err)
+
+		assert.Equal(t, uint64(1), sessionState.Cid)
+		assert.Equal(t, uint64(2), sessionState.Kid)
+		assert.Equal(t, "test", sessionState.CommonName)
+		assert.Equal(t, "127.0.0.1", sessionState.Ipaddr)
+
+		client.Shutdown()
+
+		wg.Done()
+	}()
+	err = client.Connect()
+	assert.NoError(t, err)
+	wg.Wait()
+}
+
+func sendLine(t *testing.T, conn net.Conn, msg string) {
+	_, err := fmt.Fprint(conn, msg)
+	assert.NoError(t, err)
+}
+
+func readLine(t *testing.T, reader *bufio.Reader) (msg string) {
+	line, err := reader.ReadString('\n')
+	assert.NoError(t, err)
+	return strings.TrimSpace(line)
+}
diff --git a/internal/utils/slices_test.go b/internal/utils/slices_test.go
new file mode 100644
index 0000000..98874bc
--- /dev/null
+++ b/internal/utils/slices_test.go
@@ -0,0 +1,13 @@
+package utils
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestCastToSlice(t *testing.T) {
+	stringSlice := CastToSlice[string]([]any{"string1", "string2", "string3"})
+
+	assert.Equal(t, stringSlice, []string{"string1", "string2", "string3"})
+}