Repository: jkroepke/openvpn-auth-oauth2
Pull request #38 (2023-09-30 16:10:28)

general: Refactor code, enable more linters
**What this PR does / why we need it**:

**Which issue this PR fixes** *(optional, in `fixes #<issue number>(, fixes #<issue_number>, ...)` format, will close that issue when PR gets merged)*: fixes #

**Special notes for your reviewer**:

**PR Readiness Checklist**:

Complete these before marking the PR as `ready to review`:

- [ ] the `CHANGELOG.md` release notes have been updated to reflect any significant (and particularly user-facing) changes introduced by this PR


sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=jkroepke_openvpn-auth-oauth2&pullRequest=38)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=CODE_SMELL) [1 Code Smell](https://sonarcloud.io/project/issues?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=jkroepke_openvpn-auth-oauth2&pullRequest=38) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&metric=new_duplicated_lines_density&view=list)


sonarcloud[bot]: SonarCloud Quality Gate failed.&nbsp; &nbsp; [![Quality Gate failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/failed-16px.png 'Quality Gate failed')](https://sonarcloud.io/dashboard?id=jkroepke_openvpn-auth-oauth2&pullRequest=38)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=CODE_SMELL) [1 Code Smell](https://sonarcloud.io/project/issues?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=jkroepke_openvpn-auth-oauth2&pullRequest=38) No Coverage information  
[![6.7%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/10-16px.png '6.7%')](https://sonarcloud.io/component_measures?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&metric=new_duplicated_lines_density&view=list) [6.7% Duplication](https://sonarcloud.io/component_measures?id=jkroepke_openvpn-auth-oauth2&pullRequest=38&metric=new_duplicated_lines_density&view=list)



![idea](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/light_bulb-16px.png 'idea') Catch issues before they fail your Quality Gate with our IDE extension ![sonarlint](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/sonarlint-16px.png 'sonarlint') [SonarLint](https://www.sonarsource.com/products/sonarlint/features/connected-mode/?referrer=sonarcloud-welcome)
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/38?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
Attention: `25 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`a18aef8`)](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/commit/a18aef881cc1835073296e3864034e491956f837?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) 69.17% compared to head [(`1691ea2`)](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/38?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) 71.03%.

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


```diff
@@            Coverage Diff             @@
##             main      #38      +/-   ##
==========================================
+ Coverage   69.17%   71.03%   +1.86%     
==========================================
  Files          15       15              
  Lines         931      939       +8     
==========================================
+ Hits          644      667      +23     
+ Misses        241      228      -13     
+ Partials       46       44       -2     
```


| [Files](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/38?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/38?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==) | `88.17% <100.00%> (ø)` | |
| [internal/oauth2/handler.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/38?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=) | `43.88% <100.00%> (ø)` | |
| [internal/oauth2/provider.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/38?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) | `74.44% <100.00%> (ø)` | |
| [internal/oauth2/providers/generic/main.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/38?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-aW50ZXJuYWwvb2F1dGgyL3Byb3ZpZGVycy9nZW5lcmljL21haW4uZ28=) | `66.66% <100.00%> (ø)` | |
| [internal/openvpn/client.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/38?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=) | `82.04% <100.00%> (ø)` | |
| [internal/oauth2/providers/generic/endpoints.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/38?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-aW50ZXJuYWwvb2F1dGgyL3Byb3ZpZGVycy9nZW5lcmljL2VuZHBvaW50cy5nbw==) | `0.00% <0.00%> (ø)` | |
| [cmd/root.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/38?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=) | `67.64% <68.00%> (+13.74%)` | :arrow_up: |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/38?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/.editorconfig b/.editorconfig
index 86f636a..617059b 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -7,5 +7,5 @@ charset = utf-8
 trim_trailing_whitespace = true
 
 [*.go]
-max_line_length = 120
+max_line_length = 160
 indent_style = tab
diff --git a/.golangci.yaml b/.golangci.yaml
new file mode 100644
index 0000000..6cc9c9d
--- /dev/null
+++ b/.golangci.yaml
@@ -0,0 +1,48 @@
+linters:
+  enable-all: true
+  disable:
+    - deadcode
+    - scopelint
+    - exhaustivestruct
+    - structcheck
+    - interfacer
+    - maligned
+    - golint
+    - nosnakecase
+    - ifshort
+    - varcheck
+    - depguard
+    - exhaustruct
+    - gomnd
+    - dupl
+    - forbidigo
+    - funlen
+    - ireturn
+    - goerr113
+
+issues:
+  exclude-rules:
+    - path: _test\.go
+      linters:
+        - lll
+        - gocognit
+        - cyclop
+        - wrapcheck
+        - maintidx
+
+linters-settings:
+  varnamelen:
+    ignore-names:
+      - tt
+    ignore-decls:
+      - err error
+      - ok bool
+      - w http.ResponseWriter
+      - r *http.Request
+      - l net.Listener
+      - t reflect.Type
+      - wg sync.WaitGroup
+      - k *koanf.Koanf
+      - mu sync.Mutex
+  lll:
+    line-length: 160
diff --git a/cmd/root.go b/cmd/root.go
index a798eaa..bff356f 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -1,7 +1,6 @@
 package cmd
 
 import (
-	"errors"
 	"fmt"
 	"io"
 	"log/slog"
@@ -11,6 +10,7 @@ import (
 	"runtime"
 	"strings"
 	"syscall"
+	"time"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2"
@@ -22,127 +22,78 @@ import (
 	"github.com/knadh/koanf/providers/posflag"
 	"github.com/knadh/koanf/v2"
 	"github.com/mitchellh/mapstructure"
+	flag "github.com/spf13/pflag"
 )
 
-var k = koanf.New(".")
+func Execute(args []string, logWriter io.Writer, version, commit, date string) int {
+	var err error
 
-func Execute(version, commit, date string, w io.Writer) int {
-	logger := slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{
+	logger := slog.New(slog.NewTextHandler(logWriter, &slog.HandlerOptions{
 		AddSource: false,
 	}))
 
-	var err error
+	flagSet := config.FlagSet()
+	if err = flagSet.Parse(args[1:]); err != nil {
+		logger.Error(fmt.Errorf("error parsing cli args: %w", err).Error())
 
-	f := config.FlagSet()
-	if err = f.Parse(os.Args[1:]); err != nil {
-		logger.Error(utils.StringConcat("error parsing cli args: ", err.Error()))
 		return 1
 	}
 
-	if versionFlag, _ := f.GetBool("version"); versionFlag {
+	if versionFlag, _ := flagSet.GetBool("version"); versionFlag {
 		fmt.Printf("version: %s\ncommit: %s\ndate: %s\ngo: %s\n", version, commit, date, runtime.Version())
-		return 0
-	}
-
-	configFile, _ := f.GetString("config")
-	if configFile != "" {
-		if err := k.Load(file.Provider(configFile), yaml.Parser()); err != nil {
-			logger.Error(utils.StringConcat("error loading config: ", err.Error()))
-			return 1
-		}
-	}
 
-	if err = k.Load(posflag.Provider(f, ".", k), nil); err != nil {
-		logger.Error(utils.StringConcat("error loading config: ", err.Error()))
-		return 1
+		return 0
 	}
 
-	err = k.Load(env.ProviderWithValue("CONFIG_", ".", func(s string, v string) (string, interface{}) {
-		key := strings.Replace(strings.ToLower(strings.TrimPrefix(s, "CONFIG_")), "_", ".", -1)
-
-		// If there is a space in the value, split the value into a slice by the space.
-		if strings.Contains(v, " ") {
-			return key, strings.Split(v, " ")
-		}
-
-		// Otherwise, return the plain string.
-		return key, v
-	}), nil)
-
+	conf, err := loadConfig(flagSet)
 	if err != nil {
-		logger.Error(utils.StringConcat("error loading config: ", err.Error()))
-		return 1
-	}
-
-	var conf config.Config
-	unmarshalConf := koanf.UnmarshalConf{
-		DecoderConfig: &mapstructure.DecoderConfig{
-			DecodeHook: mapstructure.ComposeDecodeHookFunc(
-				mapstructure.StringToTimeDurationHookFunc(),
-				mapstructure.TextUnmarshallerHookFunc(),
-				config.StringToUrlHookFunc(),
-				config.StringToTemplateHookFunc(),
-			),
-			Metadata:         nil,
-			Result:           &conf,
-			WeaklyTypedInput: true,
-		},
-	}
+		logger.Error(fmt.Errorf("error loading config: %w", err).Error())
 
-	if err = k.UnmarshalWithConf("", &conf, unmarshalConf); err != nil {
-		logger.Error(utils.StringConcat("error loading config: ", err.Error()))
 		return 1
 	}
 
-	logger, err = configureLogger(&conf, w)
+	logger, err = configureLogger(conf, logWriter)
 	if err != nil {
-		logger.Error(utils.StringConcat("error configure logger: ", err.Error()))
-		return 1
-	}
+		logger := slog.New(slog.NewTextHandler(logWriter, &slog.HandlerOptions{
+			AddSource: false,
+		}))
+		logger.Error(fmt.Errorf("error configure logging: %w", err).Error())
 
-	if err = config.Validate(&conf); err != nil {
-		logger.Error(utils.StringConcat("error validating config: ", err.Error()))
 		return 1
 	}
 
-	oidcClient, err := oauth2.NewProvider(logger, &conf)
+	oidcClient, err := oauth2.NewProvider(logger, conf)
 	if err != nil {
 		logger.Error(err.Error())
+
 		return 1
 	}
 
-	openvpnClient := openvpn.NewClient(logger, &conf)
+	openvpnClient := openvpn.NewClient(logger, conf)
+	defer openvpnClient.Shutdown()
+
 	done := make(chan int, 1)
 
 	go func() {
-		server := &http.Server{
-			Addr:     conf.Http.Listen,
-			ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
-			Handler:  oauth2.Handler(logger, oidcClient, &conf, openvpnClient),
-		}
+		if err = startHTTPListener(conf, logger, oidcClient, openvpnClient); err == nil {
+			done <- 0
 
-		if conf.Http.Tls {
-			logger.Info(utils.StringConcat("HTTPS server listen on ", conf.Http.Listen, " with base url ", conf.Http.BaseUrl.String()))
-			if err = server.ListenAndServeTLS(conf.Http.CertFile, conf.Http.KeyFile); err != nil {
-				logger.Error(err.Error())
-				done <- 1
-			}
-		} else {
-			logger.Info(utils.StringConcat("HTTP server listen on ", conf.Http.Listen, " with base url ", conf.Http.BaseUrl.String()))
-			if err = server.ListenAndServe(); err != nil {
-				logger.Error(err.Error())
-				done <- 1
-			}
+			return
 		}
+
+		logger.Error(fmt.Errorf("error http listener: %w", err).Error())
+		done <- 1
 	}()
 
 	go func() {
-		defer openvpnClient.Shutdown()
-		if err = openvpnClient.Connect(); err != nil {
-			logger.Error(err.Error())
-			done <- 1
+		if err = openvpnClient.Connect(); err == nil {
+			done <- 0
+
+			return
 		}
-		done <- 0
+
+		logger.Error(fmt.Errorf("error OpenVPN: %w", err).Error())
+		done <- 1
 	}()
 
 	termCh := make(chan os.Signal, 1)
@@ -153,16 +104,52 @@ func Execute(version, commit, date string, w io.Writer) int {
 		return returnCode
 	case sig := <-termCh:
 		logger.Info(utils.StringConcat("receiving signal: ", sig.String()))
-		openvpnClient.Shutdown()
+
 		return 0
 	}
 }
 
-func configureLogger(conf *config.Config, w io.Writer) (*slog.Logger, error) {
+func startHTTPListener(
+	conf config.Config, logger *slog.Logger, provider oauth2.Provider, openvpnClient *openvpn.Client,
+) error {
+	server := &http.Server{
+		Addr:              conf.HTTP.Listen,
+		ReadHeaderTimeout: 3 * time.Second,
+		ErrorLog:          slog.NewLogLogger(logger.Handler(), slog.LevelError),
+		Handler:           oauth2.Handler(logger, provider, conf, openvpnClient),
+	}
+
+	if conf.HTTP.TLS {
+		logger.Info(utils.StringConcat(
+			"HTTPS server listen on ", conf.HTTP.Listen, " with base url ", conf.HTTP.BaseURL.String(),
+		))
+
+		err := server.ListenAndServeTLS(conf.HTTP.CertFile, conf.HTTP.KeyFile)
+		if err != nil {
+			return fmt.Errorf("ListenAndServeTLS: %w", err)
+		}
+
+		return nil
+	}
+
+	logger.Info(utils.StringConcat(
+		"HTTP server listen on ", conf.HTTP.Listen, " with base url ", conf.HTTP.BaseURL.String(),
+	))
+
+	err := server.ListenAndServe()
+	if err != nil {
+		return fmt.Errorf("ListenAndServe: %w", err)
+	}
+
+	return nil
+}
+
+func configureLogger(conf config.Config, writer io.Writer) (*slog.Logger, error) {
 	var level slog.Level
 
-	if err := level.UnmarshalText([]byte(conf.Log.Level)); err != nil {
-		return nil, err
+	err := level.UnmarshalText([]byte(conf.Log.Level))
+	if err != nil {
+		return nil, fmt.Errorf("unable to parse log level: %w", err)
 	}
 
 	opts := &slog.HandlerOptions{
@@ -172,10 +159,70 @@ func configureLogger(conf *config.Config, w io.Writer) (*slog.Logger, error) {
 
 	switch conf.Log.Format {
 	case "json":
-		return slog.New(slog.NewJSONHandler(w, opts)), nil
+		return slog.New(slog.NewJSONHandler(writer, opts)), nil
 	case "console":
-		return slog.New(slog.NewTextHandler(w, opts)), nil
+		return slog.New(slog.NewTextHandler(writer, opts)), nil
 	default:
-		return nil, errors.New(utils.StringConcat("Unknown log format: ", conf.Log.Format))
+		return nil, fmt.Errorf("unknown log format: %s", conf.Log.Format)
+	}
+}
+
+func loadConfig(flagSet *flag.FlagSet) (config.Config, error) {
+	var err error
+
+	k := koanf.New(".")
+
+	configFile, _ := flagSet.GetString("config")
+	if configFile != "" {
+		if err := k.Load(file.Provider(configFile), yaml.Parser()); err != nil {
+			return config.Config{}, fmt.Errorf("error from file provider: %w", err)
+		}
 	}
+
+	if err = k.Load(posflag.Provider(flagSet, ".", k), nil); err != nil {
+		return config.Config{}, fmt.Errorf("error from posflag provider: %w", err)
+	}
+
+	err = k.Load(env.ProviderWithValue("CONFIG_", ".",
+		func(envKey string, envValue string) (string, interface{}) {
+			key := strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(envKey, "CONFIG_")), "_", ".")
+
+			// If there is a space in the value, split the value into a slice by the space.
+			if strings.Contains(envValue, " ") {
+				return key, strings.Split(envValue, " ")
+			}
+
+			// Otherwise, return the plain string.
+			return key, envValue
+		}), nil,
+	)
+
+	if err != nil {
+		return config.Config{}, fmt.Errorf("error from env provider: %w", err)
+	}
+
+	var conf config.Config
+	unmarshalConf := koanf.UnmarshalConf{
+		DecoderConfig: &mapstructure.DecoderConfig{
+			DecodeHook: mapstructure.ComposeDecodeHookFunc(
+				mapstructure.StringToTimeDurationHookFunc(),
+				mapstructure.TextUnmarshallerHookFunc(),
+				config.StringToURLHookFunc(),
+				config.StringToTemplateHookFunc(),
+			),
+			Metadata:         nil,
+			Result:           &conf,
+			WeaklyTypedInput: true,
+		},
+	}
+
+	if err = k.UnmarshalWithConf("", &conf, unmarshalConf); err != nil {
+		return config.Config{}, fmt.Errorf("error unmarschal config: %w", err)
+	}
+
+	if err = config.Validate(conf); err != nil {
+		return config.Config{}, fmt.Errorf("error validating logging: %w", err)
+	}
+
+	return conf, nil
 }
diff --git a/internal/config/config.go b/internal/config/config.go
index ca66f77..acf22d2 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -1,174 +1,83 @@
+//nolint:lll,goerr113
 package config
 
 import (
 	"errors"
 	"fmt"
-	"html/template"
 	"net/url"
 	"os"
-	"reflect"
 	"slices"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
 	flag "github.com/spf13/pflag"
 )
 
-type Config struct {
-	ConfigFile string   `koanf:"config"`
-	Log        *Log     `koanf:"log"`
-	Http       *Http    `koanf:"http"`
-	OpenVpn    *OpenVpn `koanf:"openvpn"`
-	Oauth2     *OAuth2  `koanf:"oauth2"`
-}
-
-type Http struct {
-	Listen             string             `koanf:"listen"`
-	CertFile           string             `koanf:"cert"`
-	KeyFile            string             `koanf:"key"`
-	Tls                bool               `koanf:"tls"`
-	BaseUrl            *url.URL           `koanf:"baseurl"`
-	Secret             string             `koanf:"secret"`
-	CallbackTemplate   *template.Template `koanf:"callback-template-path"`
-	Check              *HttpCheck         `koanf:"check"`
-	EnableProxyHeaders bool               `koanf:"enable-proxy-headers"`
-}
-
-type HttpCheck struct {
-	IpAddr bool `koanf:"ipaddr"`
-}
-
-type Log struct {
-	Format string `koanf:"format"`
-	Level  string `koanf:"level"`
-}
-
-type OpenVpn struct {
-	Addr          *url.URL       `koanf:"addr"`
-	Password      string         `koanf:"password"`
-	Bypass        *OpenVpnBypass `koanf:"bypass"`
-	AuthTokenUser bool           `koanf:"auth-token-user"`
-}
-
-type OpenVpnBypass struct {
-	CommonNames []string `koanf:"cn"`
-}
-
-type OAuth2 struct {
-	Issuer          *url.URL         `koanf:"issuer"`
-	Provider        string           `koanf:"provider"`
-	AuthorizeParams string           `koanf:"authorize-params"`
-	Endpoints       *OAuth2Endpoints `koanf:"endpoint"`
-	Client          *OAuth2Client    `koanf:"client"`
-	Scopes          []string         `koanf:"scopes"`
-	Pkce            bool             `koanf:"pkce"`
-	Validate        *OAuth2Validate  `koanf:"validate"`
-}
-
-type OAuth2Client struct {
-	Id     string `koanf:"id"`
-	Secret string `koanf:"secret"`
-}
-
-type OAuth2Endpoints struct {
-	Discovery *url.URL `koanf:"discovery"`
-	Auth      *url.URL `koanf:"auth"`
-	Token     *url.URL `koanf:"token"`
-}
-
-type OAuth2Validate struct {
-	Groups     []string `koanf:"groups"`
-	Roles      []string `koanf:"roles"`
-	IpAddr     bool     `koanf:"ipaddr"`
-	Issuer     bool     `koanf:"issuer"`
-	CommonName string   `koanf:"common_name"`
-}
-
+// FlagSet configure the command line parser using [flag] library.
 func FlagSet() *flag.FlagSet {
-	f := flag.NewFlagSet("openvpn-auth-oauth2", flag.ContinueOnError)
-	f.Usage = func() {
+	flagSet := flag.NewFlagSet("openvpn-auth-oauth2", flag.ContinueOnError)
+	flagSet.Usage = func() {
 		fmt.Println("Usage of openvpn-auth-oauth2:")
-		f.PrintDefaults()
+		flagSet.PrintDefaults()
 		os.Exit(0)
 	}
 
-	f.String("config", "", "path to one .yaml config files. (env: CONFIG_CONFIG)")
-	f.String("log.format", "json", "log format. json or console (env: CONFIG_LOG_FORMAT)")
-	f.String("log.level", "info", "log level. (env: CONFIG_LOG_LEVEL)")
-	f.String("http.listen", ":9000", "listen addr for client listener. (env: CONFIG_HTTP_LISTEN)")
-	f.Bool("http.tls", false, "enable TLS listener. (env: CONFIG_HTTP_TLS)")
-	f.String("http.baseurl", "http://localhost:9000", "listen addr for client listener. (env: CONFIG_HTTP_BASEURL)")
-	f.String("http.secret", "", "Cookie secret. (env: CONFIG_HTTP_SECRET)")
-	f.String("http.key", "", "Path to tls server key. (env: CONFIG_HTTP_KEY)")
-	f.String("http.cert", "", "Path to tls server certificate. (env: CONFIG_HTTP_CERT)")
-	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.Bool("http.check.ipaddr", false, "Check if client IP in http and VPN is equal. (env: CONFIG_HTTP_CHECK_IPADDR)")
-	f.Bool("http.enable-proxy-headers", false, "Use X-Forward-For http header for client ips. (env: CONFIG_HTTP_ENABLE_PROXY_HEADERS)")
-	f.String("openvpn.addr", "unix:///run/openvpn/server.sock", "openvpn management interface addr. Must start with unix:// or tcp:// (env: CONFIG_OPENVPN_ADDR)")
-	f.String("openvpn.password", "", "openvpn management interface password. (env: CONFIG_OPENVPN_PASSWORD)")
-	f.Bool("openvpn.auth-token-user", true, "Define auth-token-user for all sessions. (env: CONFIG_OPENVPN_AUTH_TOKEN_USER)")
-	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", "generic", "oauth2 provider. (env: CONFIG_OAUTH2_PROVIDER)")
-	f.String("oauth2.authorize-params", "", "additional url query parameter to authorize endpoint. (env: CONFIG_OAUTH2_AUTHORIZE_ENDPOINT)")
-	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)")
-	f.String("oauth2.client.id", "", "oauth2 client id. (env: CONFIG_OAUTH2_CLIENT_ID)")
-	f.String("oauth2.client.secret", "", "oauth2 client secret. (env: CONFIG_OAUTH2_CLIENT_SECRET)")
-	f.StringSlice("oauth2.validate.groups", []string{}, "oauth2 required user groups. (env: CONFIG_OAUTH2_VALIDATE_GROUPS)")
-	f.StringSlice("oauth2.validate.roles", []string{}, "oauth2 required user roles. (env: CONFIG_OAUTH2_VALIDATE_ROLES)")
-	f.Bool("oauth2.validate.ipaddr", false, "validate client ipaddr between VPN and OIDC token. (env: CONFIG_OAUTH2_VALIDATE_IPADDR)")
-	f.Bool("oauth2.validate.issuer", true, "validate issuer from oidc discovery. (env: CONFIG_OAUTH2_VALIDATE_ISSUER)")
-	f.String("oauth2.validate.common_name", "", "validate common_name from OpenVPN with IDToken claim. (env: CONFIG_OAUTH2_VALIDATE_COMMON_NAME)")
-	f.StringSlice("oauth2.scopes", []string{"openid", "profile"}, "oauth2 token scopes. (env: CONFIG_OAUTH2_SCOPES)")
-	f.Bool("version", false, "shows versions")
-
-	return f
+	//nolint: lll
+	flagSet.String("config", "", "path to one .yaml config files. (env: CONFIG_CONFIG)")
+	flagSet.String("log.format", "console", "log format. json or console (env: CONFIG_LOG_FORMAT)")
+	flagSet.String("log.level", "info", "log level. (env: CONFIG_LOG_LEVEL)")
+	flagSet.String("http.listen", ":9000", "listen addr for client listener. (env: CONFIG_HTTP_LISTEN)")
+	flagSet.Bool("http.tls", false, "enable TLS listener. (env: CONFIG_HTTP_TLS)")
+	flagSet.String("http.baseurl", "http://localhost:9000", "listen addr for client listener. (env: CONFIG_HTTP_BASEURL)")
+	flagSet.String("http.secret", "", "Cookie secret. (env: CONFIG_HTTP_SECRET)")
+	flagSet.String("http.key", "", "Path to tls server key. (env: CONFIG_HTTP_KEY)")
+	flagSet.String("http.cert", "", "Path to tls server certificate. (env: CONFIG_HTTP_CERT)")
+	flagSet.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)")
+	flagSet.Bool("http.check.ipaddr", false, "Check if client IP in http and VPN is equal. (env: CONFIG_HTTP_CHECK_IPADDR)")
+	flagSet.Bool("http.enable-proxy-headers", false, "Use X-Forward-For http header for client ips. (env: CONFIG_HTTP_ENABLE_PROXY_HEADERS)")
+	flagSet.String("openvpn.addr", "unix:///run/openvpn/server.sock", "openvpn management interface addr. Must start with unix:// or tcp:// (env: CONFIG_OPENVPN_ADDR)")
+	flagSet.String("openvpn.password", "", "openvpn management interface password. (env: CONFIG_OPENVPN_PASSWORD)")
+	flagSet.Bool("openvpn.auth-token-user", true, "Define auth-token-user for all sessions. (env: CONFIG_OPENVPN_AUTH_TOKEN_USER)")
+	flagSet.StringSlice("openvpn.bypass.cn", []string{}, "bypass oauth authentication for CNs. (env: CONFIG_OAUTH2_BYPASS_CN)")
+	flagSet.String("oauth2.issuer", "", "oauth2 issuer. (env: CONFIG_OAUTH2_ISSUER)")
+	flagSet.String("oauth2.provider", "generic", "oauth2 provider. (env: CONFIG_OAUTH2_PROVIDER)")
+	flagSet.String("oauth2.authorize-params", "", "additional url query parameter to authorize endpoint. (env: CONFIG_OAUTH2_AUTHORIZE_ENDPOINT)")
+	flagSet.String("oauth2.endpoint.discovery", "", "custom oauth2 discovery url. (env: CONFIG_OAUTH2_ENDPOINT_DISCOVERY)")
+	flagSet.String("oauth2.endpoint.auth", "", "custom oauth2 auth endpoint. (env: CONFIG_OAUTH2_ENDPOINT_AUTH)")
+	flagSet.String("oauth2.endpoint.token", "", "custom oauth2 token endpoint. (env: CONFIG_OAUTH2_ENDPOINT_TOKEN)")
+	flagSet.String("oauth2.client.id", "", "oauth2 client id. (env: CONFIG_OAUTH2_CLIENT_ID)")
+	flagSet.String("oauth2.client.secret", "", "oauth2 client secret. (env: CONFIG_OAUTH2_CLIENT_SECRET)")
+	flagSet.StringSlice("oauth2.validate.groups", []string{}, "oauth2 required user groups. (env: CONFIG_OAUTH2_VALIDATE_GROUPS)")
+	flagSet.StringSlice("oauth2.validate.roles", []string{}, "oauth2 required user roles. (env: CONFIG_OAUTH2_VALIDATE_ROLES)")
+	flagSet.Bool("oauth2.validate.ipaddr", false, "validate client ipaddr between VPN and oidc token. (env: CONFIG_OAUTH2_VALIDATE_IPADDR)")
+	flagSet.Bool("oauth2.validate.issuer", true, "validate issuer from oidc discovery. (env: CONFIG_OAUTH2_VALIDATE_ISSUER)")
+	flagSet.String("oauth2.validate.common_name", "", "validate common_name from OpenVPN with IDToken claim. (env: CONFIG_OAUTH2_VALIDATE_COMMON_NAME)")
+	flagSet.StringSlice("oauth2.scopes", []string{"openid", "profile"}, "oauth2 token scopes. (env: CONFIG_OAUTH2_SCOPES)")
+	flagSet.Bool("version", false, "shows versions")
+
+	return flagSet
 }
 
-func Validate(conf *Config) error {
-	for key, value := range map[string]any{
-		"http":    conf.Http,
-		"oauth2":  conf.Oauth2,
-		"openvpn": conf.OpenVpn,
-		"log":     conf.Log,
-	} {
-		if reflect.ValueOf(value).IsNil() {
-			return errors.New(utils.StringConcat(key, " is nil"))
-		}
-	}
-
-	for key, value := range map[string]any{
-		"http.check":       conf.Http.Check,
-		"oauth2.client":    conf.Oauth2.Client,
-		"oauth2.endpoints": conf.Oauth2.Endpoints,
-		"oauth2.validate":  conf.Oauth2.Validate,
-		"openvpn.bypass":   conf.OpenVpn.Bypass,
-	} {
-		if reflect.ValueOf(value).IsNil() {
-			return errors.New(utils.StringConcat(key, " is nil"))
-		}
-	}
-
+// Validate validates the config.
+func Validate(conf Config) error {
 	for key, value := range map[string]string{
-		"http.secret":      conf.Http.Secret,
-		"oauth2.client.id": conf.Oauth2.Client.Id,
+		"http.secret":      conf.HTTP.Secret,
+		"oauth2.client.id": conf.OAuth2.Client.ID,
 	} {
 		if value == "" {
-			return errors.New(utils.StringConcat(key, " is required"))
+			return fmt.Errorf("%s is required", key)
 		}
 	}
 
 	for key, value := range map[string]*url.URL{
-		"http.baseurl":  conf.Http.BaseUrl,
-		"oauth2.issuer": conf.Oauth2.Issuer,
+		"http.baseurl":  conf.HTTP.BaseURL,
+		"oauth2.issuer": conf.OAuth2.Issuer,
 	} {
-		if utils.IsUrlEmpty(value) {
+		if utils.IsURLEmpty(value) {
 			return errors.New(utils.StringConcat(key, " is required"))
 		}
 	}
 
-	if !slices.Contains([]int{16, 24, 32}, len(conf.Http.Secret)) {
+	if !slices.Contains([]int{16, 24, 32}, len(conf.HTTP.Secret)) {
 		return errors.New("http.secret requires a length of 16, 24 or 32")
 	}
 
@@ -177,13 +86,13 @@ func Validate(conf *Config) error {
 	}
 
 	for key, uri := range map[string]*url.URL{
-		"http.baseurl":              conf.Http.BaseUrl,
-		"oauth2.issuer":             conf.Oauth2.Issuer,
-		"oauth2.endpoint.discovery": conf.Oauth2.Endpoints.Discovery,
-		"oauth2.endpoint.token":     conf.Oauth2.Endpoints.Token,
-		"oauth2.endpoint.auth":      conf.Oauth2.Endpoints.Auth,
+		"http.baseurl":              conf.HTTP.BaseURL,
+		"oauth2.issuer":             conf.OAuth2.Issuer,
+		"oauth2.endpoint.discovery": conf.OAuth2.Endpoints.Discovery,
+		"oauth2.endpoint.token":     conf.OAuth2.Endpoints.Token,
+		"oauth2.endpoint.auth":      conf.OAuth2.Endpoints.Auth,
 	} {
-		if utils.IsUrlEmpty(uri) {
+		if utils.IsURLEmpty(uri) {
 			continue
 		}
 
diff --git a/internal/config/decode_hooks.go b/internal/config/decode_hooks.go
index b67f01d..a639062 100644
--- a/internal/config/decode_hooks.go
+++ b/internal/config/decode_hooks.go
@@ -1,29 +1,37 @@
+//nolint:goerr113, wrapcheck
 package config
 
 import (
-	"errors"
+	"fmt"
 	"html/template"
 	"net/url"
 	"reflect"
 
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
 	"github.com/mitchellh/mapstructure"
 )
 
-func StringToUrlHookFunc() mapstructure.DecodeHookFunc {
+// StringToURLHookFunc parse a string to [url.URL].
+func StringToURLHookFunc() mapstructure.DecodeHookFuncType {
 	return func(
 		f reflect.Type,
 		t reflect.Type,
-		data interface{}) (interface{}, error) {
+		data interface{},
+	) (interface{}, error) {
 		if f.Kind() != reflect.String {
 			return data, nil
 		}
+
 		if t != reflect.TypeOf(url.URL{}) {
 			return data, nil
 		}
 
+		dataString, ok := data.(string)
+		if !ok {
+			return nil, fmt.Errorf("unable to cast to string")
+		}
+
 		// Convert it by parsing
-		uri, err := url.Parse(data.(string))
+		uri, err := url.Parse(dataString)
 		if err != nil {
 			return nil, err
 		}
@@ -33,21 +41,24 @@ func StringToUrlHookFunc() mapstructure.DecodeHookFunc {
 		}
 
 		if uri.Scheme == "" {
-			return nil, errors.New(utils.StringConcat("invalid URL ", data.(string), ": empty scheme"))
+			return nil, fmt.Errorf("invalid URL %s: empty scheme", dataString)
 		}
 
 		if uri.Host == "" && uri.Path == "" {
-			return nil, errors.New(utils.StringConcat("invalid URL ", data.(string), ": empty hostname"))
+			return nil, fmt.Errorf("invalid URL %s: empty hostname", dataString)
 		}
 
 		return uri, nil
 	}
 }
-func StringToTemplateHookFunc() mapstructure.DecodeHookFunc {
+
+// StringToTemplateHookFunc parse a string to [template.Template].
+func StringToTemplateHookFunc() mapstructure.DecodeHookFuncType {
 	return func(
 		f reflect.Type,
 		t reflect.Type,
-		data interface{}) (interface{}, error) {
+		data interface{},
+	) (interface{}, error) {
 		if f.Kind() != reflect.String {
 			return data, nil
 		}
@@ -56,10 +67,24 @@ func StringToTemplateHookFunc() mapstructure.DecodeHookFunc {
 			return data, nil
 		}
 
-		if data.(string) == "" {
+		dataString, ok := data.(string)
+		if !ok {
+			return nil, fmt.Errorf("unable to cast to string")
+		}
+
+		if dataString == "" {
 			return template.Template{}, nil
 		}
 
-		return template.New("callback").ParseFiles(data.(string))
+		var err error
+
+		tmpl := template.New("callback")
+		tmpl, err = tmpl.ParseFiles(dataString)
+
+		if err != nil {
+			return template.Template{}, fmt.Errorf("error paring template files: %w", err)
+		}
+
+		return tmpl, err
 	}
 }
diff --git a/internal/config/errors.go b/internal/config/errors.go
new file mode 100644
index 0000000..640f319
--- /dev/null
+++ b/internal/config/errors.go
@@ -0,0 +1,5 @@
+package config
+
+import "errors"
+
+var ErrRequired = errors.New("required")
diff --git a/internal/config/types.go b/internal/config/types.go
new file mode 100644
index 0000000..5d25451
--- /dev/null
+++ b/internal/config/types.go
@@ -0,0 +1,76 @@
+package config
+
+import (
+	"html/template"
+	"net/url"
+)
+
+type Config struct {
+	ConfigFile string  `koanf:"config"`
+	Log        Log     `koanf:"log"`
+	HTTP       HTTP    `koanf:"http"`
+	OpenVpn    OpenVpn `koanf:"openvpn"`
+	OAuth2     OAuth2  `koanf:"oauth2"`
+}
+
+type HTTP struct {
+	Listen             string             `koanf:"listen"`
+	CertFile           string             `koanf:"cert"`
+	KeyFile            string             `koanf:"key"`
+	TLS                bool               `koanf:"tls"`
+	BaseURL            *url.URL           `koanf:"baseurl"`
+	Secret             string             `koanf:"secret"`
+	CallbackTemplate   *template.Template `koanf:"callback-template-path"`
+	Check              HTTPCheck          `koanf:"check"`
+	EnableProxyHeaders bool               `koanf:"enable-proxy-headers"`
+}
+
+type HTTPCheck struct {
+	IPAddr bool `koanf:"ipaddr"`
+}
+
+type Log struct {
+	Format string `koanf:"format"`
+	Level  string `koanf:"level"`
+}
+
+type OpenVpn struct {
+	Addr          *url.URL      `koanf:"addr"`
+	Password      string        `koanf:"password"`
+	Bypass        OpenVpnBypass `koanf:"bypass"`
+	AuthTokenUser bool          `koanf:"auth-token-user"`
+}
+
+type OpenVpnBypass struct {
+	CommonNames []string `koanf:"cn"`
+}
+
+type OAuth2 struct {
+	Issuer          *url.URL        `koanf:"issuer"`
+	Provider        string          `koanf:"provider"`
+	AuthorizeParams string          `koanf:"authorize-params"`
+	Endpoints       OAuth2Endpoints `koanf:"endpoint"`
+	Client          OAuth2Client    `koanf:"client"`
+	Scopes          []string        `koanf:"scopes"`
+	Pkce            bool            `koanf:"pkce"`
+	Validate        OAuth2Validate  `koanf:"validate"`
+}
+
+type OAuth2Client struct {
+	ID     string `koanf:"id"`
+	Secret string `koanf:"secret"`
+}
+
+type OAuth2Endpoints struct {
+	Discovery *url.URL `koanf:"discovery"`
+	Auth      *url.URL `koanf:"auth"`
+	Token     *url.URL `koanf:"token"`
+}
+
+type OAuth2Validate struct {
+	Groups     []string `koanf:"groups"`
+	Roles      []string `koanf:"roles"`
+	IPAddr     bool     `koanf:"ipaddr"`
+	Issuer     bool     `koanf:"issuer"`
+	CommonName string   `koanf:"common_name"`
+}
diff --git a/internal/oauth2/callback.go b/internal/oauth2/callback.go
index a879885..63c3558 100644
--- a/internal/oauth2/callback.go
+++ b/internal/oauth2/callback.go
@@ -1,7 +1,7 @@
 package oauth2
 
-// language=html
-const callbackHtml = `
+// language=html.
+const callbackHTML = `
 <!doctype html>
 <html>
 <head><title>Login successfully</title></head>
diff --git a/internal/oauth2/handler.go b/internal/oauth2/handler.go
index ee54405..3a9b91a 100644
--- a/internal/oauth2/handler.go
+++ b/internal/oauth2/handler.go
@@ -12,170 +12,222 @@ import (
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/types"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
 	"github.com/zitadel/oidc/v2/pkg/client/rp"
 	"github.com/zitadel/oidc/v2/pkg/oidc"
 )
 
-func Handler(logger *slog.Logger, oidcProvider *Provider, conf *config.Config, openvpnClient *openvpn.Client) *http.ServeMux {
+func Handler(logger *slog.Logger, provider Provider, conf config.Config, openvpnClient *openvpn.Client) *http.ServeMux {
+	basePath := strings.TrimSuffix(conf.HTTP.BaseURL.Path, "/")
+
 	mux := http.NewServeMux()
 	mux.Handle("/", http.NotFoundHandler())
-	mux.Handle(strings.TrimSuffix(conf.Http.BaseUrl.Path, "/")+"/oauth2/start", oauth2Start(logger, oidcProvider, conf, openvpnClient))
-	mux.Handle(strings.TrimSuffix(conf.Http.BaseUrl.Path, "/")+"/oauth2/callback", oauth2Callback(logger, oidcProvider, conf, openvpnClient))
+	mux.Handle(utils.StringConcat(basePath, "/oauth2/start"), oauth2Start(logger, provider, conf, openvpnClient))
+	mux.Handle(utils.StringConcat(basePath, "/oauth2/callback"), oauth2Callback(logger, provider, conf, openvpnClient))
 
 	return mux
 }
 
-func oauth2Start(logger *slog.Logger, oidcProvider *Provider, conf *config.Config, openvpnClient *openvpn.Client) http.Handler {
+func oauth2Start(logger *slog.Logger, provider Provider, conf config.Config, openvpnClient *openvpn.Client) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		sessionState := r.URL.Query().Get("state")
 		if sessionState == "" {
 			w.WriteHeader(http.StatusBadRequest)
+
 			return
 		}
 
 		session := state.NewEncoded(sessionState)
-		if err := session.Decode(conf.Http.Secret); err != nil {
+		if err := session.Decode(conf.HTTP.Secret); err != nil {
 			logger.Warn(utils.StringConcat("invalid state: ", err.Error()))
 			logger.Debug(sessionState)
 			w.WriteHeader(http.StatusBadRequest)
+
 			return
 		}
 
-		if conf.Http.Check.IpAddr {
-			clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
-			if strings.HasPrefix(r.RemoteAddr, "[") {
-				clientIP = utils.StringConcat("[", clientIP, "]")
-			}
-
-			if err != nil {
-				logger.Warn(err.Error(),
-					"common_name", session.CommonName,
-					"cid", session.Cid,
-					"kid", session.Kid,
-				)
-
-				openvpnClient.SendCommandf(`client-deny %d %d "%s"`, session.Cid, session.Kid, "client rejected")
-				w.WriteHeader(http.StatusInternalServerError)
-				return
-			}
-
-			if conf.Http.EnableProxyHeaders {
-				if fwdAddress := r.Header.Get("X-Forwarded-For"); fwdAddress != "" {
-					clientIP = strings.Split(fwdAddress, ", ")[0]
-				}
-			}
-
-			if clientIP != session.Ipaddr {
-				logger.Warn(utils.StringConcat("http client ip ", clientIP, " and vpn ip ", session.Ipaddr, " is different."),
-					"common_name", session.CommonName,
-					"cid", session.Cid,
-					"kid", session.Kid,
-				)
+		logger = logger.With(
+			slog.String("common_name", session.CommonName),
+			slog.Uint64("cid", session.Cid),
+			slog.Uint64("kid", session.Kid),
+		)
 
-				openvpnClient.SendCommandf(`client-deny %d %d "%s"`, session.Cid, session.Kid, "client rejected")
+		if conf.HTTP.Check.IPAddr {
+			ok, httpStatusCode := checkClientIPAddr(r, logger, session, openvpnClient, conf)
+			if !ok {
+				w.WriteHeader(httpStatusCode)
 
-				w.WriteHeader(http.StatusForbidden)
 				return
 			}
 		}
 
-		logger.Info("initialize authorization via oauth2",
-			"common_name", session.CommonName,
-			"cid", session.Cid,
-			"kid", session.Kid,
-		)
+		logger.Info("initialize authorization via oauth2")
 
 		rp.AuthURLHandler(func() string {
 			return sessionState
-		}, oidcProvider.RelyingParty).ServeHTTP(w, r)
+		}, provider.RelyingParty).ServeHTTP(w, r)
 	})
 }
 
-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], sessionState string, rp rp.RelyingParty) {
+func checkClientIPAddr(
+	r *http.Request, logger *slog.Logger, session state.State, openvpnClient *openvpn.Client, conf config.Config,
+) (bool, int) {
+	clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
+	if err != nil {
+		logger.Warn(err.Error())
+
+		_, err := openvpnClient.SendCommandf(`client-deny %d %d "%s"`, session.Cid, session.Kid, "client rejected")
+		if err != nil {
+			logger.Warn(err.Error())
+		}
+
+		return false, http.StatusInternalServerError
+	}
+
+	if strings.HasPrefix(r.RemoteAddr, "[") {
+		clientIP = utils.StringConcat("[", clientIP, "]")
+	}
+
+	if conf.HTTP.EnableProxyHeaders {
+		if fwdAddress := r.Header.Get("X-Forwarded-For"); fwdAddress != "" {
+			clientIP = strings.Split(fwdAddress, ", ")[0]
+		}
+	}
+
+	if clientIP != session.Ipaddr {
+		reason := utils.StringConcat("http client ip ", clientIP, " and vpn ip ", session.Ipaddr, " is different.")
+		logger.Warn(reason)
+
+		_, err := openvpnClient.SendCommandf(`client-deny %d %d "%s"`, session.Cid, session.Kid, reason)
+		if err != nil {
+			logger.Warn(err.Error())
+		}
+
+		return false, http.StatusForbidden
+	}
+
+	return true, 0
+}
+
+func oauth2Callback(
+	logger *slog.Logger, provider Provider, conf config.Config, openvpnClient *openvpn.Client,
+) http.Handler {
+	return rp.CodeExchangeHandler(func(
+		w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], sessionState string,
+		rp rp.RelyingParty,
+	) {
 		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 		defer cancel()
 
+		logger = logger.With(
+			slog.String("subject", tokens.IDTokenClaims.Subject),
+			slog.String("preferred_username", tokens.IDTokenClaims.PreferredUsername),
+		)
+
 		session := state.NewEncoded(sessionState)
-		if err := session.Decode(conf.Http.Secret); err != nil {
-			logger.Warn(err.Error(),
-				"subject", tokens.IDTokenClaims.Subject,
-				"preferred_username", tokens.IDTokenClaims.PreferredUsername,
-			)
+		if err := session.Decode(conf.HTTP.Secret); err != nil {
+			logger.Warn(err.Error())
 			logger.Debug(sessionState)
-			http.Error(w, err.Error(), http.StatusInternalServerError)
+			writeError(w, logger, conf, http.StatusInternalServerError, "invalidSession", err.Error())
+
 			return
 		}
 
-		user, err := oidcProvider.OidcProvider.GetUser(ctx, tokens)
+		logger = logger.With(
+			slog.String("common_name", session.CommonName),
+			slog.Uint64("cid", session.Cid),
+			slog.Uint64("kid", session.Kid),
+		)
+
+		user, err := provider.OIDC.GetUser(ctx, tokens)
 		if err != nil {
-			logger.Error(err.Error(),
-				"common_name", session.CommonName,
-				"cid", session.Cid,
-				"kid", session.Kid,
-			)
+			logger.Error(err.Error())
+
+			_, err = openvpnClient.SendCommandf(`client-deny %d %d "%s"`, session.Cid, session.Kid, "unable to fetch user data")
+			if err != nil {
+				logger.Warn(err.Error())
+			}
 
-			openvpnClient.SendCommandf(`client-deny %d %d "%s"`, session.Cid, session.Kid, "unable to fetch user data")
+			writeError(w, logger, conf, http.StatusInternalServerError, "fetchUser", err.Error())
 
 			return
 		}
 
-		if err := oidcProvider.OidcProvider.CheckUser(ctx, session, user, tokens); err != nil {
-			logger.Warn(err.Error(),
-				"subject", user.Subject,
-				"preferred_username", user.PreferredUsername,
-				"common_name", session.CommonName,
-				"cid", session.Cid,
-				"kid", session.Kid,
-			)
-
-			openvpnClient.SendCommandf(`client-deny %d %d "%s"`, session.Cid, session.Kid, "client rejected")
-
-			if conf.Http.CallbackTemplate == nil || conf.Http.CallbackTemplate.Tree == nil {
-				http.Error(w, err.Error(), http.StatusInternalServerError)
-			} else {
-				err := conf.Http.CallbackTemplate.Execute(w, map[string]string{
-					"errorDesc": err.Error(),
-					"errorType": "tokenValidation",
-				})
-
-				if err != nil {
-					logger.Error("executing template:", err)
-					w.WriteHeader(http.StatusInternalServerError)
-				}
+		err = provider.OIDC.CheckUser(ctx, session, user, tokens)
+		if err != nil {
+			reason := err.Error()
+			logger.Warn(reason)
+
+			_, err = openvpnClient.SendCommandf(`client-deny %d %d "%s"`, session.Cid, session.Kid, "client rejected")
+			if err != nil {
+				logger.Warn(err.Error())
 			}
 
+			writeError(w, logger, conf, http.StatusInternalServerError, "tokenValidation", reason)
+
 			return
 		}
 
-		logger.Info("successful authorization via oauth2",
-			"subject", user.Subject,
-			"preferred_username", user.PreferredUsername,
-			"common_name", session.CommonName,
-			"cid", session.Cid,
-			"kid", session.Kid,
-		)
+		logger.Info("successful authorization via oauth2")
 
 		if conf.OpenVpn.AuthTokenUser {
-			username := session.CommonName
-			if user.PreferredUsername != "" {
-				username = user.PreferredUsername
-			} else if user.Subject != "" {
-				username = user.Subject
-			}
-
-			b64Username := base64.StdEncoding.EncodeToString([]byte(username))
-			openvpnClient.SendCommandf("client-auth %d %d\npush \"auth-token-user %s\"\nEND", session.Cid, session.Kid, b64Username)
+			username := getAuthTokenUsername(session, user)
+			_, err = openvpnClient.SendCommandf("client-auth %d %d\npush \"auth-token-user %s\"\nEND", session.Cid, session.Kid, username)
 		} else {
-			openvpnClient.SendCommandf("client-auth-nt %d %d", session.Cid, session.Kid)
+			_, err = openvpnClient.SendCommandf("client-auth-nt %d %d", session.Cid, session.Kid)
 		}
 
-		if conf.Http.CallbackTemplate == nil || conf.Http.CallbackTemplate.Tree == nil {
-			_, _ = w.Write([]byte(callbackHtml))
-		} else if err := conf.Http.CallbackTemplate.Execute(w, map[string]string{}); err != nil {
-			logger.Error("executing template:", err)
-			w.WriteHeader(http.StatusInternalServerError)
+		if err != nil {
+			logger.Warn(err.Error())
 		}
-	}, oidcProvider.RelyingParty)
+
+		writeSuccess(w, conf, logger)
+	}, provider.RelyingParty)
+}
+
+func getAuthTokenUsername(session state.State, user types.UserData) string {
+	username := session.CommonName
+	if user.PreferredUsername != "" {
+		username = user.PreferredUsername
+	} else if user.Subject != "" {
+		username = user.Subject
+	}
+
+	return base64.StdEncoding.EncodeToString([]byte(username))
+}
+
+func writeError(w http.ResponseWriter, logger *slog.Logger, conf config.Config, httpCode int, errorType, errorDesc string) {
+	if conf.HTTP.CallbackTemplate == nil || conf.HTTP.CallbackTemplate.Tree == nil {
+		http.Error(w, utils.StringConcat(errorType, ": ", errorDesc), httpCode)
+
+		return
+	}
+
+	err := conf.HTTP.CallbackTemplate.Execute(w, map[string]string{
+		"errorDesc": errorDesc,
+		"errorType": errorType,
+	})
+	if err != nil {
+		logger.Error("executing template:", err)
+		w.WriteHeader(http.StatusInternalServerError)
+
+		return
+	}
+
+	w.WriteHeader(httpCode)
+}
+
+func writeSuccess(w http.ResponseWriter, conf config.Config, logger *slog.Logger) {
+	if conf.HTTP.CallbackTemplate == nil || conf.HTTP.CallbackTemplate.Tree == nil {
+		_, _ = w.Write([]byte(callbackHTML))
+
+		return
+	}
+
+	err := conf.HTTP.CallbackTemplate.Execute(w, map[string]string{})
+	if err != nil {
+		logger.Error("executing template:", err)
+		w.WriteHeader(http.StatusInternalServerError)
+	}
 }
diff --git a/internal/oauth2/provider.go b/internal/oauth2/provider.go
index 76b4cb3..34e0fb0 100644
--- a/internal/oauth2/provider.go
+++ b/internal/oauth2/provider.go
@@ -1,8 +1,7 @@
 package oauth2
 
 import (
-	"context"
-	"errors"
+	"fmt"
 	"log/slog"
 	"net/http"
 	"strings"
@@ -10,44 +9,29 @@ import (
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/providers/generic"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/providers/github"
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/types"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
 	"github.com/zitadel/oidc/v2/pkg/client/rp"
 	httphelper "github.com/zitadel/oidc/v2/pkg/http"
-	"github.com/zitadel/oidc/v2/pkg/oidc"
 	"golang.org/x/oauth2"
 )
 
-type Provider struct {
-	rp.RelyingParty
-	OidcProvider
-}
-
-type OidcProvider interface {
-	CheckUser(ctx context.Context, session *state.State, user *types.UserData, tokens *oidc.Tokens[*oidc.IDTokenClaims]) error
-	GetEndpoints(conf *config.Config) (*oauth2.Endpoint, error)
-	GetName() string
-	GetUser(ctx context.Context, tokens *oidc.Tokens[*oidc.IDTokenClaims]) (*types.UserData, error)
-}
-
-// NewProvider returns a [rp.RelyingParty] instance
-func NewProvider(logger *slog.Logger, conf *config.Config) (*Provider, error) {
-	oidcProvider, err := NewOidcProvider(conf)
+// NewProvider returns a [rp.RelyingParty] instance.
+func NewProvider(logger *slog.Logger, conf config.Config) (Provider, error) {
+	provider, err := newOidcProvider(conf)
 	if err != nil {
-		return nil, err
+		return Provider{}, err
 	}
 
-	redirectURI := utils.StringConcat(strings.TrimSuffix(conf.Http.BaseUrl.String(), "/"), "/oauth2/callback")
+	redirectURI := utils.StringConcat(strings.TrimSuffix(conf.HTTP.BaseURL.String(), "/"), "/oauth2/callback")
 
-	cookieKey := []byte(conf.Http.Secret)
+	cookieKey := []byte(conf.HTTP.Secret)
 	cookieOpt := []httphelper.CookieHandlerOpt{
 		httphelper.WithMaxAge(1800),
-		httphelper.WithPath(utils.StringConcat(strings.TrimSuffix(conf.Http.BaseUrl.Path, "/"), "/oauth2")),
-		httphelper.WithDomain(conf.Http.BaseUrl.Hostname()),
+		httphelper.WithPath(utils.StringConcat(strings.TrimSuffix(conf.HTTP.BaseURL.Path, "/"), "/oauth2")),
+		httphelper.WithDomain(conf.HTTP.BaseURL.Hostname()),
 	}
 
-	if conf.Http.BaseUrl.Scheme == "http" {
+	if conf.HTTP.BaseURL.Scheme == "http" {
 		cookieOpt = append(cookieOpt, httphelper.WithUnsecure())
 	}
 
@@ -56,14 +40,13 @@ func NewProvider(logger *slog.Logger, conf *config.Config) (*Provider, error) {
 	options := []rp.Option{
 		rp.WithCookieHandler(cookieHandler),
 		rp.WithErrorHandler(func(w http.ResponseWriter, r *http.Request, errorType string, errorDesc string, state string) {
-			if conf.Http.CallbackTemplate == nil {
+			if conf.HTTP.CallbackTemplate == nil {
 				http.Error(w, errorType+": "+errorDesc, http.StatusInternalServerError)
 			} else {
-				err := conf.Http.CallbackTemplate.Execute(w, map[string]string{
+				err := conf.HTTP.CallbackTemplate.Execute(w, map[string]string{
 					"errorDesc": errorDesc,
 					"errorType": errorType,
 				})
-
 				if err != nil {
 					logger.Error("executing template:", err)
 					w.WriteHeader(http.StatusInternalServerError)
@@ -72,70 +55,92 @@ func NewProvider(logger *slog.Logger, conf *config.Config) (*Provider, error) {
 		}),
 	}
 
-	if conf.Oauth2.Pkce {
+	if conf.OAuth2.Pkce {
 		options = append(options, rp.WithPKCE(cookieHandler))
 	}
 
-	endpoints, err := oidcProvider.GetEndpoints(conf)
+	endpoints, err := provider.GetEndpoints(conf)
 	if err != nil {
-		return nil, err
+		return Provider{}, fmt.Errorf("error getting endpoints: %w", err)
 	}
 
-	if endpoints == nil {
-		if !utils.IsUrlEmpty(conf.Oauth2.Endpoints.Discovery) {
-			logger.Info(utils.StringConcat("discover OIDC auto configuration with provider ", oidcProvider.GetName(), "for issuer ", conf.Oauth2.Issuer.String(), "with custom discovery url", conf.Oauth2.Endpoints.Discovery.String()))
-			options = append(options, rp.WithCustomDiscoveryUrl(conf.Oauth2.Endpoints.Discovery.String()))
-		} else {
-			logger.Info(utils.StringConcat("discover OIDC auto configuration with provider ", oidcProvider.GetName(), "for issuer ", conf.Oauth2.Issuer.String()))
-		}
+	if endpoints == (oauth2.Endpoint{}) {
+		if !utils.IsURLEmpty(conf.OAuth2.Endpoints.Discovery) {
+			logger.Info(utils.StringConcat(
+				"discover oidc auto configuration with provider ",
+				provider.GetName(), "for issuer ", conf.OAuth2.Issuer.String(),
+				"with custom discovery url", conf.OAuth2.Endpoints.Discovery.String(),
+			))
 
-		relayingParty, err := rp.NewRelyingPartyOIDC(
-			conf.Oauth2.Issuer.String(),
-			conf.Oauth2.Client.Id,
-			conf.Oauth2.Client.Secret,
-			redirectURI,
-			conf.Oauth2.Scopes,
-			options...,
-		)
-
-		if err != nil {
-			return nil, err
+			options = append(options, rp.WithCustomDiscoveryUrl(conf.OAuth2.Endpoints.Discovery.String()))
+		} else {
+			logger.Info(utils.StringConcat(
+				"discover oidc auto configuration with provider ",
+				provider.GetName(), "for issuer ", conf.OAuth2.Issuer.String(),
+			))
 		}
 
-		return &Provider{
-			RelyingParty: relayingParty,
-			OidcProvider: oidcProvider,
-		}, nil
+		return newProviderWithDiscovery(conf, provider, options, redirectURI)
 	}
 
-	logger.Info(utils.StringConcat("manually configure oauth2 provider with provider ", oidcProvider.GetName(), " and endpoints ", endpoints.AuthURL, " and ", endpoints.TokenURL))
+	logger.Info(utils.StringConcat(
+		"manually configure oauth2 provider with provider ",
+		provider.GetName(), " and endpoints ", endpoints.AuthURL, " and ", endpoints.TokenURL,
+	))
 
+	return newProviderWithEndpoints(conf, provider, options, redirectURI, endpoints)
+}
+
+func newProviderWithEndpoints(
+	conf config.Config, provider oidcProvider, options []rp.Option, redirectURI string, endpoints oauth2.Endpoint,
+) (Provider, error) {
 	rpConfig := &oauth2.Config{
-		ClientID:     conf.Oauth2.Client.Id,
-		ClientSecret: conf.Oauth2.Client.Secret,
+		ClientID:     conf.OAuth2.Client.ID,
+		ClientSecret: conf.OAuth2.Client.Secret,
 		RedirectURL:  redirectURI,
-		Scopes:       conf.Oauth2.Scopes,
-		Endpoint:     *endpoints,
+		Scopes:       conf.OAuth2.Scopes,
+		Endpoint:     endpoints,
 	}
 
 	relayingParty, err := rp.NewRelyingPartyOAuth(rpConfig, options...)
 	if err != nil {
-		return nil, err
+		return Provider{}, fmt.Errorf("newProviderWithEndpoints: %w", err)
+	}
+
+	return Provider{
+		RelyingParty: relayingParty,
+		OIDC:         provider,
+	}, nil
+}
+
+func newProviderWithDiscovery(
+	conf config.Config, provider oidcProvider, options []rp.Option, redirectURI string,
+) (Provider, error) {
+	relayingParty, err := rp.NewRelyingPartyOIDC(
+		conf.OAuth2.Issuer.String(),
+		conf.OAuth2.Client.ID,
+		conf.OAuth2.Client.Secret,
+		redirectURI,
+		conf.OAuth2.Scopes,
+		options...,
+	)
+	if err != nil {
+		return Provider{}, fmt.Errorf("newProviderWithDiscovery: %w", err)
 	}
 
-	return &Provider{
+	return Provider{
 		RelyingParty: relayingParty,
-		OidcProvider: oidcProvider,
+		OIDC:         provider,
 	}, nil
 }
 
-func NewOidcProvider(conf *config.Config) (OidcProvider, error) {
-	switch conf.Oauth2.Provider {
+func newOidcProvider(conf config.Config) (oidcProvider, error) {
+	switch conf.OAuth2.Provider {
 	case "generic":
 		return generic.NewProvider(conf), nil
 	case "github":
 		return github.NewProvider(conf), nil
 	default:
-		return nil, errors.New(utils.StringConcat("unknown oauth2 provider: ", conf.Oauth2.Provider))
+		return nil, fmt.Errorf("unknown oauth2 provider: %s", conf.OAuth2.Provider)
 	}
 }
diff --git a/internal/oauth2/providers/generic/check.go b/internal/oauth2/providers/generic/check.go
index c010f9b..efecb36 100644
--- a/internal/oauth2/providers/generic/check.go
+++ b/internal/oauth2/providers/generic/check.go
@@ -2,7 +2,7 @@ package generic
 
 import (
 	"context"
-	"errors"
+	"fmt"
 	"slices"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
@@ -11,38 +11,45 @@ import (
 	"github.com/zitadel/oidc/v2/pkg/oidc"
 )
 
-func (p *Provider) CheckUser(_ context.Context, session *state.State, _ *types.UserData, tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
+func (p *Provider) CheckUser(
+	_ context.Context,
+	session state.State,
+	_ types.UserData,
+	tokens *oidc.Tokens[*oidc.IDTokenClaims],
+) error {
 	if err := p.CheckGroups(tokens); err != nil {
 		return err
 	}
+
 	if err := p.CheckRoles(tokens); err != nil {
 		return err
 	}
+
 	if err := p.CheckCommonName(session, tokens); err != nil {
 		return err
 	}
-	if err := p.CheckIpAddress(session, tokens); err != nil {
-		return err
-	}
 
-	return nil
+	return p.CheckIPAddress(session, tokens)
 }
 
 func (p *Provider) CheckGroups(tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
-	if len(p.Conf.Oauth2.Validate.Groups) == 0 {
+	if len(p.Conf.OAuth2.Validate.Groups) == 0 {
 		return nil
 	}
 
 	tokenGroups, ok := tokens.IDTokenClaims.Claims["groups"]
 	if !ok {
-		return errors.New("missing groups claim")
+		return fmt.Errorf("%w: groups", ErrMissingClaim)
 	}
 
-	tokenGroupsList := utils.CastToSlice[string](tokenGroups)
+	tokenGroupsList, err := utils.CastToSlice[string](tokenGroups)
+	if err != nil {
+		return fmt.Errorf("unable to decode token groups: %w", err)
+	}
 
-	for _, group := range p.Conf.Oauth2.Validate.Groups {
+	for _, group := range p.Conf.OAuth2.Validate.Groups {
 		if !slices.Contains(tokenGroupsList, group) {
-			return errors.New(utils.StringConcat("missing required group ", group))
+			return fmt.Errorf("%w: %s", ErrMissingRequiredGroup, group)
 		}
 	}
 
@@ -50,53 +57,60 @@ func (p *Provider) CheckGroups(tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
 }
 
 func (p *Provider) CheckRoles(tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
-	if len(p.Conf.Oauth2.Validate.Roles) == 0 {
+	if len(p.Conf.OAuth2.Validate.Roles) == 0 {
 		return nil
 	}
 
 	tokenRoles, ok := tokens.IDTokenClaims.Claims["roles"]
 	if !ok {
-		return errors.New("missing roles claim")
+		return fmt.Errorf("%w: roles", ErrMissingClaim)
 	}
 
-	tokenRolesList := utils.CastToSlice[string](tokenRoles)
+	tokenRolesList, err := utils.CastToSlice[string](tokenRoles)
+	if err != nil {
+		return fmt.Errorf("unable to decode token roles: %w", err)
+	}
 
-	for _, role := range p.Conf.Oauth2.Validate.Roles {
+	for _, role := range p.Conf.OAuth2.Validate.Roles {
 		if !slices.Contains(tokenRolesList, role) {
-			return errors.New(utils.StringConcat("missing required role ", role))
+			return fmt.Errorf("%w: %s", ErrMissingRequiredRole, role)
 		}
 	}
 
 	return nil
 }
-func (p *Provider) CheckCommonName(session *state.State, tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
-	if p.Conf.Oauth2.Validate.CommonName == "" {
+
+func (p *Provider) CheckCommonName(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].(string)
+	tokenCommonName, ok := tokens.IDTokenClaims.Claims[p.Conf.OAuth2.Validate.CommonName].(string)
 	if !ok {
-		return errors.New(utils.StringConcat("missing ", p.Conf.Oauth2.Validate.CommonName, " claim"))
+		return fmt.Errorf("%w: %s", ErrMissingClaim, p.Conf.OAuth2.Validate.CommonName)
 	}
 
 	if tokenCommonName != session.CommonName {
-		return errors.New(utils.StringConcat("common_name mismatch: openvpn client: ", tokenCommonName, " - oidc token: ", session.CommonName))
+		return fmt.Errorf("common_name %w: openvpn client: %s - oidc token: %s",
+			ErrMismatch, tokenCommonName, session.CommonName)
 	}
 
 	return nil
 }
-func (p *Provider) CheckIpAddress(session *state.State, tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
-	if !p.Conf.Oauth2.Validate.IpAddr {
+
+func (p *Provider) CheckIPAddress(session state.State, tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
+	if !p.Conf.OAuth2.Validate.IPAddr {
 		return nil
 	}
 
 	tokenIpaddr, ok := tokens.IDTokenClaims.Claims["ipaddr"].(string)
 	if !ok {
-		return errors.New("missing ipaddr claim")
+		return fmt.Errorf("%w: ipaddr", ErrMissingClaim)
 	}
 
 	if tokenIpaddr != session.Ipaddr {
-		return errors.New(utils.StringConcat("ipaddr mismatch: openvpn client: ", tokenIpaddr, " - oidc token: ", session.Ipaddr))
+		return fmt.Errorf("ipaddr %w: openvpn client: %s - oidc token: %s",
+			ErrMismatch, tokenIpaddr, session.Ipaddr)
 	}
 
 	return nil
diff --git a/internal/oauth2/providers/generic/endpoints.go b/internal/oauth2/providers/generic/endpoints.go
index 5b8ff72..ea2eb5e 100644
--- a/internal/oauth2/providers/generic/endpoints.go
+++ b/internal/oauth2/providers/generic/endpoints.go
@@ -8,18 +8,18 @@ import (
 	"golang.org/x/oauth2"
 )
 
-func (p *Provider) GetEndpoints(conf *config.Config) (*oauth2.Endpoint, error) {
-	if utils.IsUrlEmpty(conf.Oauth2.Endpoints.Token) && utils.IsUrlEmpty(conf.Oauth2.Endpoints.Auth) {
-		return nil, nil
+func (p *Provider) GetEndpoints(conf config.Config) (oauth2.Endpoint, error) {
+	if utils.IsURLEmpty(conf.OAuth2.Endpoints.Token) && utils.IsURLEmpty(conf.OAuth2.Endpoints.Auth) {
+		return oauth2.Endpoint{}, nil
 	}
 
-	if (!utils.IsUrlEmpty(conf.Oauth2.Endpoints.Token) && utils.IsUrlEmpty(conf.Oauth2.Endpoints.Auth)) ||
-		(utils.IsUrlEmpty(conf.Oauth2.Endpoints.Token) && !utils.IsUrlEmpty(conf.Oauth2.Endpoints.Auth)) {
-		return nil, errors.New("both oauth2.endpoints.tokenUrl and oauth2.endpoints.authUrl are required")
+	if (!utils.IsURLEmpty(conf.OAuth2.Endpoints.Token) && utils.IsURLEmpty(conf.OAuth2.Endpoints.Auth)) ||
+		(utils.IsURLEmpty(conf.OAuth2.Endpoints.Token) && !utils.IsURLEmpty(conf.OAuth2.Endpoints.Auth)) {
+		return oauth2.Endpoint{}, errors.New("both oauth2.endpoints.tokenUrl and oauth2.endpoints.authUrl are required")
 	}
 
-	return &oauth2.Endpoint{
-		AuthURL:  conf.Oauth2.Endpoints.Auth.String(),
-		TokenURL: conf.Oauth2.Endpoints.Token.String(),
+	return oauth2.Endpoint{
+		AuthURL:  conf.OAuth2.Endpoints.Auth.String(),
+		TokenURL: conf.OAuth2.Endpoints.Token.String(),
 	}, nil
 }
diff --git a/internal/oauth2/providers/generic/errors.go b/internal/oauth2/providers/generic/errors.go
new file mode 100644
index 0000000..e708320
--- /dev/null
+++ b/internal/oauth2/providers/generic/errors.go
@@ -0,0 +1,10 @@
+package generic
+
+import "errors"
+
+var (
+	ErrMismatch             = errors.New("mismatch")
+	ErrMissingClaim         = errors.New("missing claim")
+	ErrMissingRequiredRole  = errors.New("missing required role")
+	ErrMissingRequiredGroup = errors.New("missing required group")
+)
diff --git a/internal/oauth2/providers/generic/main.go b/internal/oauth2/providers/generic/main.go
index 36200ba..dcb16b7 100644
--- a/internal/oauth2/providers/generic/main.go
+++ b/internal/oauth2/providers/generic/main.go
@@ -7,10 +7,10 @@ import (
 const Name = "generic"
 
 type Provider struct {
-	Conf *config.Config
+	Conf config.Config
 }
 
-func NewProvider(conf *config.Config) *Provider {
+func NewProvider(conf config.Config) *Provider {
 	return &Provider{
 		Conf: conf,
 	}
diff --git a/internal/oauth2/providers/generic/user.go b/internal/oauth2/providers/generic/user.go
index 48d08d2..28926ba 100644
--- a/internal/oauth2/providers/generic/user.go
+++ b/internal/oauth2/providers/generic/user.go
@@ -7,7 +7,7 @@ import (
 	"github.com/zitadel/oidc/v2/pkg/oidc"
 )
 
-func (p *Provider) GetUser(_ context.Context, tokens *oidc.Tokens[*oidc.IDTokenClaims]) (*types.UserData, error) {
+func (p *Provider) GetUser(_ context.Context, tokens *oidc.Tokens[*oidc.IDTokenClaims]) (types.UserData, error) {
 	var (
 		preferredUsername string
 		subject           string
@@ -21,7 +21,7 @@ func (p *Provider) GetUser(_ context.Context, tokens *oidc.Tokens[*oidc.IDTokenC
 		subject = tokens.IDTokenClaims.Subject
 	}
 
-	return &types.UserData{
+	return types.UserData{
 		PreferredUsername: preferredUsername,
 		Subject:           subject,
 	}, nil
diff --git a/internal/oauth2/providers/github/api.go b/internal/oauth2/providers/github/api.go
index b9216ed..7993440 100644
--- a/internal/oauth2/providers/github/api.go
+++ b/internal/oauth2/providers/github/api.go
@@ -3,7 +3,7 @@ package github
 import (
 	"context"
 	"encoding/json"
-	"errors"
+	"fmt"
 	"net/http"
 	"regexp"
 
@@ -11,16 +11,16 @@ import (
 )
 
 // Pagination URL patterns
-// https://developer.github.com/v3/#pagination
+// https://developer.GitHub.com/v3/#pagination
 var (
 	reNext = regexp.MustCompile("<([^>]+)>; rel=\"next\"")
 	reLast = regexp.MustCompile("<([^>]+)>; rel=\"last\"")
 )
 
-func get[T any](ctx context.Context, accessToken string, apiUrl string, t *T) (string, error) {
-	req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiUrl, nil)
+func get[T any](ctx context.Context, accessToken string, apiURL string, data *T) (string, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
 	if err != nil {
-		return "", err
+		return "", fmt.Errorf("error creating request context with URL %s: %w", apiURL, err)
 	}
 
 	req.Header.Add("Authorization", utils.StringConcat("Bearer ", accessToken))
@@ -28,18 +28,18 @@ func get[T any](ctx context.Context, accessToken string, apiUrl string, t *T) (s
 
 	resp, err := http.DefaultClient.Do(req)
 	if err != nil {
-		return "", err
+		return "", fmt.Errorf("error calling GitHub api %s: %w", apiURL, err)
 	} else if resp.StatusCode != http.StatusOK {
-		return "", errors.New(utils.StringConcat("error from github api ", apiUrl, ", http status code: ", resp.Status))
+		return "", fmt.Errorf("error from GitHub api %s http status code: %d", apiURL, resp.StatusCode)
 	}
 
 	defer resp.Body.Close()
 
-	if err := json.NewDecoder(resp.Body).Decode(t); err != nil {
-		return "", err
+	if err = json.NewDecoder(resp.Body).Decode(data); err != nil {
+		return "", fmt.Errorf("unable to decode json: %w", err)
 	}
 
-	return getPagination(apiUrl, resp), nil
+	return getPagination(apiURL, resp), nil
 }
 
 func getPagination(apiURL string, resp *http.Response) string {
diff --git a/internal/oauth2/providers/github/check.go b/internal/oauth2/providers/github/check.go
index 90d616b..510646b 100644
--- a/internal/oauth2/providers/github/check.go
+++ b/internal/oauth2/providers/github/check.go
@@ -2,6 +2,7 @@ package github
 
 import (
 	"context"
+	"fmt"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/types"
@@ -19,68 +20,90 @@ type teamType struct {
 	Slug string  `json:"slug"`
 }
 
-func (p *Provider) CheckUser(ctx context.Context, _ *state.State, _ *types.UserData, tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
+func (p *Provider) CheckUser(
+	ctx context.Context, _ state.State, _ types.UserData, tokens *oidc.Tokens[*oidc.IDTokenClaims],
+) error {
+	//nolint: exhaustruct
 	tokens.IDTokenClaims = &oidc.IDTokenClaims{
 		Claims: make(map[string]any),
 	}
 
-	if len(p.Provider.Conf.Oauth2.Validate.Groups) != 0 {
-		apiUrl := "https://api.github.com/user/orgs"
+	if err := p.CheckOrgs(ctx, tokens); err != nil {
+		return err
+	}
 
-		var groups []interface{}
+	return p.CheckTeams(ctx, tokens)
+}
 
-		for {
-			var (
-				orgs []orgType
-				err  error
-			)
+func (p *Provider) CheckTeams(ctx context.Context, tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
+	if len(p.Provider.Conf.OAuth2.Validate.Roles) != 0 {
+		return nil
+	}
 
-			if apiUrl, err = get[[]orgType](ctx, tokens.AccessToken, apiUrl, &orgs); err != nil {
-				return err
-			}
-			for _, org := range orgs {
-				groups = append(groups, org.Login)
-			}
+	apiURL := "https://api.github.com/user/teams"
 
-			if apiUrl == "" {
-				break
-			}
-		}
+	var roles []interface{}
 
-		tokens.IDTokenClaims.Claims["groups"] = groups
+	for {
+		var (
+			teams []teamType
+			err   error
+		)
 
-		if err := p.CheckGroups(tokens); err != nil {
+		if apiURL, err = get[[]teamType](ctx, tokens.AccessToken, apiURL, &teams); err != nil {
 			return err
 		}
-	}
-	if len(p.Provider.Conf.Oauth2.Validate.Roles) != 0 {
-		apiUrl := "https://api.github.com/user/teams"
-
-		var roles []interface{}
-
-		for {
-			var (
-				teams []teamType
-				err   error
-			)
-
-			if apiUrl, err = get[[]teamType](ctx, tokens.AccessToken, apiUrl, &teams); err != nil {
-				return err
-			}
-			for _, team := range teams {
-				roles = append(roles, utils.StringConcat(team.Org.Login, ":", team.Slug))
-			}
-
-			if apiUrl == "" {
-				break
-			}
+
+		for _, team := range teams {
+			roles = append(roles, utils.StringConcat(team.Org.Login, ":", team.Slug))
 		}
 
-		tokens.IDTokenClaims.Claims["roles"] = roles
+		if apiURL == "" {
+			break
+		}
+	}
+
+	tokens.IDTokenClaims.Claims["roles"] = roles
+
+	if err := p.CheckRoles(tokens); err != nil {
+		return fmt.Errorf("CheckRoles: %w", err)
+	}
+
+	return nil
+}
+
+func (p *Provider) CheckOrgs(ctx context.Context, tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
+	if len(p.Provider.Conf.OAuth2.Validate.Groups) != 0 {
+		return nil
+	}
+
+	apiURL := "https://api.github.com/user/orgs"
 
-		if err := p.CheckRoles(tokens); err != nil {
+	var groups []interface{}
+
+	for {
+		var (
+			orgs []orgType
+			err  error
+		)
+
+		if apiURL, err = get[[]orgType](ctx, tokens.AccessToken, apiURL, &orgs); err != nil {
 			return err
 		}
+
+		for _, org := range orgs {
+			groups = append(groups, org.Login)
+		}
+
+		if apiURL == "" {
+			break
+		}
+	}
+
+	tokens.IDTokenClaims.Claims["groups"] = groups
+
+	if err := p.CheckGroups(tokens); err != nil {
+		return fmt.Errorf("CheckGroups: %w", err)
 	}
 
 	return nil
diff --git a/internal/oauth2/providers/github/endpoints.go b/internal/oauth2/providers/github/endpoints.go
index e27ddad..bef86b5 100644
--- a/internal/oauth2/providers/github/endpoints.go
+++ b/internal/oauth2/providers/github/endpoints.go
@@ -9,18 +9,18 @@ import (
 	"golang.org/x/oauth2/github"
 )
 
-func (p *Provider) GetEndpoints(conf *config.Config) (*oauth2.Endpoint, error) {
-	if !utils.IsUrlEmpty(conf.Oauth2.Endpoints.Token) && !utils.IsUrlEmpty(conf.Oauth2.Endpoints.Auth) {
-		return &github.Endpoint, nil
+func (p *Provider) GetEndpoints(conf config.Config) (oauth2.Endpoint, error) {
+	if !utils.IsURLEmpty(conf.OAuth2.Endpoints.Token) && !utils.IsURLEmpty(conf.OAuth2.Endpoints.Auth) {
+		return github.Endpoint, nil
 	}
 
-	if (!utils.IsUrlEmpty(conf.Oauth2.Endpoints.Token) && utils.IsUrlEmpty(conf.Oauth2.Endpoints.Auth)) ||
-		(utils.IsUrlEmpty(conf.Oauth2.Endpoints.Token) && !utils.IsUrlEmpty(conf.Oauth2.Endpoints.Auth)) {
-		return nil, errors.New("both oauth2.endpoints.tokenUrl and oauth2.endpoints.authUrl are required")
+	if (!utils.IsURLEmpty(conf.OAuth2.Endpoints.Token) && utils.IsURLEmpty(conf.OAuth2.Endpoints.Auth)) ||
+		(utils.IsURLEmpty(conf.OAuth2.Endpoints.Token) && !utils.IsURLEmpty(conf.OAuth2.Endpoints.Auth)) {
+		return oauth2.Endpoint{}, errors.New("both oauth2.endpoints.tokenUrl and oauth2.endpoints.authUrl are required")
 	}
 
-	return &oauth2.Endpoint{
-		AuthURL:  conf.Oauth2.Endpoints.Auth.String(),
-		TokenURL: conf.Oauth2.Endpoints.Auth.String(),
+	return oauth2.Endpoint{
+		AuthURL:  conf.OAuth2.Endpoints.Auth.String(),
+		TokenURL: conf.OAuth2.Endpoints.Auth.String(),
 	}, nil
 }
diff --git a/internal/oauth2/providers/github/main.go b/internal/oauth2/providers/github/main.go
index 477ed88..a73422a 100644
--- a/internal/oauth2/providers/github/main.go
+++ b/internal/oauth2/providers/github/main.go
@@ -11,7 +11,7 @@ type Provider struct {
 	*generic.Provider
 }
 
-func NewProvider(conf *config.Config) *Provider {
+func NewProvider(conf config.Config) *Provider {
 	return &Provider{
 		Provider: generic.NewProvider(conf),
 	}
diff --git a/internal/oauth2/providers/github/user.go b/internal/oauth2/providers/github/user.go
index 85e25d6..4f80bcc 100644
--- a/internal/oauth2/providers/github/user.go
+++ b/internal/oauth2/providers/github/user.go
@@ -17,16 +17,16 @@ type userType struct {
 	Email string `json:"email"`
 }
 
-func (p *Provider) GetUser(ctx context.Context, tokens *oidc.Tokens[*oidc.IDTokenClaims]) (*types.UserData, error) {
-	var u userType
+func (p *Provider) GetUser(ctx context.Context, tokens *oidc.Tokens[*oidc.IDTokenClaims]) (types.UserData, error) {
+	var user userType
 
-	_, err := get[userType](ctx, tokens.AccessToken, "https://api.github.com/user", &u)
+	_, err := get[userType](ctx, tokens.AccessToken, "https://api.github.com/user", &user)
 	if err != nil {
-		return nil, err
+		return types.UserData{}, err
 	}
 
-	return &types.UserData{
-		PreferredUsername: u.Login,
-		Subject:           strconv.Itoa(u.ID),
+	return types.UserData{
+		PreferredUsername: user.Login,
+		Subject:           strconv.Itoa(user.ID),
 	}, nil
 }
diff --git a/internal/oauth2/types.go b/internal/oauth2/types.go
new file mode 100644
index 0000000..9a80c9c
--- /dev/null
+++ b/internal/oauth2/types.go
@@ -0,0 +1,24 @@
+package oauth2
+
+import (
+	"context"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/types"
+	"github.com/zitadel/oidc/v2/pkg/client/rp"
+	"github.com/zitadel/oidc/v2/pkg/oidc"
+	"golang.org/x/oauth2"
+)
+
+type Provider struct {
+	rp.RelyingParty
+	OIDC oidcProvider
+}
+
+type oidcProvider interface {
+	CheckUser(ctx context.Context, session state.State, user types.UserData, tokens *oidc.Tokens[*oidc.IDTokenClaims]) error
+	GetEndpoints(conf config.Config) (oauth2.Endpoint, error)
+	GetName() string
+	GetUser(ctx context.Context, tokens *oidc.Tokens[*oidc.IDTokenClaims]) (types.UserData, error)
+}
diff --git a/internal/openvpn/client.go b/internal/openvpn/client.go
index 2743e23..3f60266 100644
--- a/internal/openvpn/client.go
+++ b/internal/openvpn/client.go
@@ -1,350 +1,103 @@
 package openvpn
 
 import (
-	"bufio"
-	"bytes"
-	"context"
 	"encoding/base64"
-	"errors"
 	"fmt"
 	"log/slog"
-	"net"
 	"net/url"
 	"slices"
 	"strings"
-	"sync"
-	"time"
 
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn/connection"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
 )
 
-type Client struct {
-	conf    *config.Config
-	conn    net.Conn
-	scanner *bufio.Scanner
-	logger  *slog.Logger
-
-	mu     sync.Mutex
-	closed bool
-
-	clientsCh         chan *ClientConnection
-	commandResponseCh chan string
-	commandsCh        chan string
-	errCh             chan error
-	shutdownCh        chan struct{}
-}
-
-var (
-	msgEnd        = []byte("ERROR:")
-	prefixSuccess = []byte("SUCCESS:")
-	prefixEnd     = []byte("END")
-	prefixVersion = []byte("OpenVPN Version:")
-)
-
-func NewClient(logger *slog.Logger, conf *config.Config) *Client {
-	return &Client{
-		conf:   conf,
-		logger: logger,
-
-		closed: false,
-		mu:     sync.Mutex{},
-
-		errCh:             make(chan error, 1),
-		clientsCh:         make(chan *ClientConnection, 10),
-		commandResponseCh: make(chan string, 10),
-		commandsCh:        make(chan string, 10),
-		shutdownCh:        make(chan struct{}, 1),
-	}
-}
-
-func (c *Client) Connect() error {
-	var err error
-	c.logger.Info(utils.StringConcat("connect to openvpn management interface ", c.conf.OpenVpn.Addr.String()))
-	switch c.conf.OpenVpn.Addr.Scheme {
-	case "tcp":
-		c.conn, err = net.Dial(c.conf.OpenVpn.Addr.Scheme, c.conf.OpenVpn.Addr.Host)
-	case "unix":
-		c.conn, err = net.Dial(c.conf.OpenVpn.Addr.Scheme, c.conf.OpenVpn.Addr.Path)
+func (c *Client) processClient(client connection.Client) error {
+	switch client.Reason {
+	case "CONNECT":
+		fallthrough
+	case "REAUTH":
+		return c.clientConnect(client)
+	case "ESTABLISHED":
+		c.clientEstablished(client)
+	case "DISCONNECT":
+		c.clientDisconnect(client)
 	default:
-		return errors.New(utils.StringConcat("unable to connect to openvpn management interface: unknown protocol ", c.conf.OpenVpn.Addr.Scheme))
-	}
-
-	if err != nil {
-		return errors.New(utils.StringConcat("unable to connect to openvpn management interface ", c.conf.OpenVpn.Addr.String(), ": ", err.Error()))
-	}
-	defer c.conn.Close()
-	c.scanner = bufio.NewScanner(c.conn)
-	c.scanner.Split(bufio.ScanLines)
-
-	var buf bytes.Buffer
-
-	if c.conf.OpenVpn.Password != "" {
-		if err := c.rawCommand(utils.StringConcat(c.conf.OpenVpn.Password, "\n")); err != nil {
-			return err
-		}
-
-		if err := c.readMessage(&buf); err != nil {
-			return err
-		}
-
-		if !strings.Contains(buf.String(), "SUCCESS: password is correct") {
-			return errors.New("wrong openvpn management interface password")
-		}
-
-		buf.Reset()
+		return fmt.Errorf("unknown client reason: %s", client.Reason)
 	}
 
-	go func() {
-		defer close(c.commandResponseCh)
-		defer close(c.clientsCh)
-
-		var buf bytes.Buffer
-
-		for {
-			buf.Reset()
-
-			if err := c.readMessage(&buf); err != nil {
-				c.errCh <- err
-				return
-			}
+	return nil
+}
 
-			if bytes.HasPrefix(buf.Bytes(), []byte(">CLIENT:")) {
-				client, err := NewClientConnection(buf.String())
-				if err != nil {
-					c.errCh <- err
-					return
-				}
+func (c *Client) clientConnect(client connection.Client) error {
+	logger := c.logger.With(
+		slog.Uint64("cid", client.Cid),
+		slog.Uint64("kid", client.Kid),
+		slog.String("reason", client.Reason),
+		slog.String("common_name", client.Env["common_name"]),
+		slog.String("username", client.Env["username"]),
+	)
 
-				c.clientsCh <- client
-			} else if bytes.HasPrefix(buf.Bytes(), prefixSuccess) || bytes.HasPrefix(buf.Bytes(), msgEnd) || bytes.HasPrefix(buf.Bytes(), prefixVersion) {
-				if bytes.HasPrefix(buf.Bytes(), msgEnd) {
-					c.logger.Warn(fmt.Sprintf("Error from OpenVPN: %s", buf.String()))
-				}
-				c.commandResponseCh <- buf.String()
-			}
-		}
-	}()
+	logger.Info("new client connection")
 
-	go func() {
-		var client *ClientConnection
+	if val, ok := client.Env["common_name"]; ok && slices.Contains(c.conf.OpenVpn.Bypass.CommonNames, val) {
+		logger.Info("client bypass authentication")
 
-		for {
-			client = <-c.clientsCh
-			if client == nil {
-				return
-			}
+		var err error
 
-			if err := c.processClient(client); err != nil {
-				c.errCh <- err
-				return
-			}
+		if c.conf.OpenVpn.AuthTokenUser {
+			tokenUsername := base64.StdEncoding.EncodeToString([]byte(client.Env["common_name"]))
+			_, err = c.SendCommandf("client-auth %d %d\npush \"auth-token-user %s\"\nEND", client.Cid, client.Kid, tokenUsername)
+		} else {
+			_, err = c.SendCommandf("client-auth-nt %d %d", client.Cid, client.Kid)
 		}
-	}()
-
-	go func() {
-		var command string
-		for {
-			command = <-c.commandsCh
-			if command == "" {
-				return
-			}
 
-			if err := c.rawCommand(command); err != nil {
-				c.errCh <- err
-				return
-			}
+		if err != nil {
+			logger.Warn(err.Error())
 		}
-	}()
 
-	respCh := make(chan string, 1)
-	go func() {
-		respCh <- c.SendCommand("hold release")
-	}()
-	select {
-	case resp := <-respCh:
-		if !strings.HasPrefix(resp, "SUCCESS:") {
-			return errors.New(utils.StringConcat("invalid openvpn management interface response: ", resp))
-		}
-	case <-time.After(10 * time.Second):
-		return errors.New("timeout while waiting from openvpn management interface response")
+		return nil
 	}
 
-	c.logger.Info("Connection to OpenVPN management interfaced established.")
-
-	if version := c.SendCommand("version"); version != "" {
-		c.logger.Info(version)
+	if !c.checkClientSsoCapabilities(logger, client) {
+		return nil
 	}
 
-	for {
-		select {
-		case err := <-c.errCh:
-			c.close()
-			if err != nil {
-				return errors.New(utils.StringConcat("OpenVPN management error: ", err.Error()))
-			}
-			return nil
-		case <-c.shutdownCh:
-			c.close()
-			return nil
-		}
+	session := state.New(client.Cid, client.Kid, client.Env["untrusted_ip"], client.Env["common_name"])
+	if err := session.Encode(c.conf.HTTP.Secret); err != nil {
+		return fmt.Errorf("error encoding state: %w", err)
 	}
-}
-
-func (c *Client) processClient(client *ClientConnection) error {
-	switch client.Reason {
-	case "CONNECT":
-		fallthrough
-	case "REAUTH":
-		c.logger.Info("new client connection",
-			"cid", client.Cid,
-			"kid", client.Kid,
-			"reason", client.Reason,
-			"common_name", client.Env["common_name"],
-			"username", client.Env["username"],
-		)
-
-		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,
-				"reason", client.Reason,
-				"common_name", client.Env["common_name"],
-				"username", client.Env["username"],
-			)
-
-			if c.conf.OpenVpn.AuthTokenUser {
-				c.SendCommandf("client-auth %d %d\npush \"auth-token-user %s\"\nEND", client.Cid, client.Kid, base64.StdEncoding.EncodeToString([]byte(client.Env["common_name"])))
-			} else {
-				c.SendCommandf("client-auth-nt %d %d", client.Cid, client.Kid)
-			}
-
-			return nil
-		}
 
-		if val, ok := client.Env["IV_SSO"]; !ok || !strings.Contains(val, "webauth") {
-			c.logger.Warn(ErrorSsoNotSupported,
-				"cid", client.Cid,
-				"kid", client.Kid,
-				"reason", client.Reason,
-				"common_name", client.Env["common_name"],
-				"username", client.Env["username"],
-			)
+	startURL := utils.StringConcat(
+		strings.TrimSuffix(c.conf.HTTP.BaseURL.String(), "/"),
+		"/oauth2/start?state=", url.QueryEscape(session.Encoded()),
+	)
 
-			c.SendCommandf(`client-deny %d %d "%s" "%s"`, client.Cid, client.Kid, ErrorSsoNotSupported, ErrorSsoNotSupported)
-			return nil
-		}
-
-		session := state.New(client.Cid, client.Kid, client.Env["untrusted_ip"], client.Env["common_name"])
-		if err := session.Encode(c.conf.Http.Secret); err != nil {
-			return errors.New(utils.StringConcat("error encoding state: ", err.Error()))
-		}
-
-		startUrl := utils.StringConcat(strings.TrimSuffix(c.conf.Http.BaseUrl.String(), "/"), "/oauth2/start?state=", url.QueryEscape(session.Encoded))
-		c.logger.Info("start pending auth",
-			"cid", client.Cid,
-			"kid", client.Kid,
-			"reason", client.Reason,
-			"common_name", client.Env["common_name"],
-			"username", client.Env["username"],
-		)
-		c.SendCommandf(`client-pending-auth %d %d "WEB_AUTH::%s" %d`, client.Cid, client.Kid, startUrl, 600)
-	case "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.Info("client disconnected",
-			"cid", client.Cid,
-			"reason", client.Reason,
-			"common_name", client.Env["common_name"],
-			"username", client.Env["username"],
-		)
-	default:
-		return fmt.Errorf("unknown client reason: %s", client.Reason)
-	}
-	return nil
-}
+	logger.Info("start pending auth")
 
-// Shutdown shutdowns the client connection
-func (c *Client) Shutdown() {
-	c.mu.Lock()
-	defer c.mu.Unlock()
-	if !c.closed {
-		c.logger.Info("shutdown connection")
-		c.shutdownCh <- struct{}{}
+	_, err := c.SendCommandf(`client-pending-auth %d %d "WEB_AUTH::%s" %d`, client.Cid, client.Kid, startURL, 600)
+	if err != nil {
+		logger.Warn(err.Error())
 	}
-}
-
-// SendCommand passes command to a given connection (adds logging and EOL character) and returns the response
-func (c *Client) SendCommand(cmd string) string {
-	c.commandsCh <- utils.StringConcat(cmd, "\n")
-	return <-c.commandResponseCh
-}
 
-// SendCommandf passes command to a given connection (adds logging and EOL character) and returns the response
-func (c *Client) SendCommandf(format string, a ...any) string {
-	return c.SendCommand(fmt.Sprintf(format, a...))
-}
-
-// rawCommand passes command to a given connection (adds logging and EOL character)
-func (c *Client) rawCommand(cmd string) error {
-	if c.logger.Enabled(context.Background(), slog.LevelDebug) {
-		c.logger.Debug(cmd)
-	}
-	_, err := c.conn.Write([]byte(cmd))
-	return err
+	return nil
 }
 
-// readMessage .
-func (c *Client) readMessage(buf *bytes.Buffer) error {
-	var line []byte
-
-	for {
-		if ok := c.scanner.Scan(); !ok {
-			return c.scanner.Err()
-		}
-
-		line = c.scanner.Bytes()
-
-		if _, err := buf.Write(line); err != nil {
-			return err
-		}
-
-		if _, err := buf.WriteString("\n"); err != nil {
-			return err
-		}
-
-		// ignore NOTIFY messages
-		if bytes.HasPrefix(line, []byte(">NOTIFY:")) {
-			if c.logger.Enabled(context.Background(), slog.LevelDebug) {
-				c.logger.Debug(buf.String())
-			}
-		} else if bytes.HasPrefix(line, []byte(">CLIENT:ENV,END")) ||
-			bytes.Index(line, prefixEnd) == 0 ||
-			bytes.Index(line, prefixSuccess) == 0 ||
-			bytes.Index(line, msgEnd) == 0 ||
-			bytes.HasPrefix(line, []byte(">HOLD:")) ||
-			bytes.HasPrefix(line, []byte(">INFO:")) ||
-			bytes.HasPrefix(line, []byte("ENTER PASSWORD:")) {
-			if c.logger.Enabled(context.Background(), slog.LevelDebug) {
-				c.logger.Debug(buf.String())
-			}
-			return nil
-		}
-	}
+func (c *Client) clientDisconnect(client connection.Client) {
+	c.logger.Info("client disconnected",
+		slog.Uint64("cid", client.Cid),
+		slog.String("reason", client.Reason),
+		slog.String("common_name", client.Env["common_name"]),
+		slog.String("username", client.Env["username"]),
+	)
 }
 
-func (c *Client) close() {
-	c.mu.Lock()
-	defer c.mu.Unlock()
-	if !c.closed {
-		c.closed = true
-		_ = c.conn.Close()
-		close(c.commandsCh)
-	}
+func (c *Client) clientEstablished(client connection.Client) {
+	c.logger.Info("client established",
+		slog.Uint64("cid", client.Cid),
+		slog.String("reason", client.Reason),
+		slog.String("common_name", client.Env["common_name"]),
+		slog.String("username", client.Env["username"]),
+	)
 }
diff --git a/internal/openvpn/client_connection.go b/internal/openvpn/client_connection.go
deleted file mode 100644
index ccdbff1..0000000
--- a/internal/openvpn/client_connection.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package openvpn
-
-import (
-	"errors"
-	"strconv"
-	"strings"
-
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
-)
-
-type ClientConnection struct {
-	Kid    uint64
-	Cid    uint64
-	Reason string
-	Env    map[string]string
-}
-
-func NewClientConnection(message string) (*ClientConnection, error) {
-	clientConnection := &ClientConnection{
-		Env: map[string]string{},
-	}
-
-	for _, line := range strings.Split(strings.TrimSpace(message), "\n") {
-		if strings.HasPrefix(line, ">CLIENT:CONNECT") ||
-			strings.HasPrefix(line, ">CLIENT:REAUTH") ||
-			strings.HasPrefix(line, ">CLIENT:DISCONNECT") ||
-			strings.HasPrefix(line, ">CLIENT:ESTABLISHED") ||
-			strings.HasPrefix(line, ">CLIENT:CR_RESPONSE") {
-			clientInfo := strings.Split(strings.TrimSpace(line), ",")
-			if len(clientInfo) == 1 {
-				return nil, errors.New(utils.StringConcat("unable to parse line ", line))
-			}
-
-			clientConnection.Reason = strings.Replace(clientInfo[0], ">CLIENT:", "", 1)
-
-			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.ParseUint(clientInfo[2], 10, 64); err != nil {
-					return nil, err
-				} else {
-					clientConnection.Kid = kid
-				}
-			}
-		} else if strings.HasPrefix(line, ">CLIENT:ENV,") {
-			clientEnv := strings.SplitN(strings.SplitN(line, ",", 2)[1], "=", 2)
-			if clientEnv[0] == "END" {
-				break
-			} else if len(clientEnv) == 2 {
-				clientConnection.Env[clientEnv[0]] = clientEnv[1]
-			} else {
-				clientConnection.Env[clientEnv[0]] = ""
-			}
-		}
-	}
-
-	if clientConnection.Reason == "" {
-		return nil, errors.New("unable to parse client reason")
-	}
-
-	return clientConnection, nil
-}
diff --git a/internal/openvpn/connection/client.go b/internal/openvpn/connection/client.go
new file mode 100644
index 0000000..0caa9ab
--- /dev/null
+++ b/internal/openvpn/connection/client.go
@@ -0,0 +1,93 @@
+package connection
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+)
+
+type Client struct {
+	Kid    uint64
+	Cid    uint64
+	Reason string
+	Env    map[string]string
+}
+
+func NewClient(message string) (Client, error) {
+	clientConnection := Client{
+		Env: map[string]string{},
+	}
+
+	var err error
+
+	for _, line := range strings.Split(strings.TrimSpace(message), "\n") {
+		if isClientReason(line) {
+			clientConnection.Reason, clientConnection.Cid, clientConnection.Kid, err = parseClientReason(line)
+			if err != nil {
+				return Client{}, err
+			}
+		} else if strings.HasPrefix(line, ">CLIENT:ENV,") {
+			envKey, envValue := parseClientEnv(line)
+			if envKey == "" {
+				break
+			}
+
+			clientConnection.Env[envKey] = envValue
+		}
+	}
+
+	if clientConnection.Reason == "" {
+		return Client{}, fmt.Errorf("unable to parse client reason from message: %s", message)
+	}
+
+	return clientConnection, nil
+}
+
+func parseClientEnv(line string) (string, string) {
+	clientEnv := strings.SplitN(strings.SplitN(line, ",", 2)[1], "=", 2)
+	if clientEnv[0] == "END" {
+		return "", ""
+	}
+
+	if len(clientEnv) == 1 {
+		return clientEnv[0], ""
+	}
+
+	return clientEnv[0], clientEnv[1]
+}
+
+func parseClientReason(line string) (string, uint64, uint64, error) {
+	clientInfo := strings.Split(strings.TrimSpace(line), ",")
+	if len(clientInfo) == 1 {
+		return "", 0, 0, fmt.Errorf("unable to parse line '%s': %w", line, ErrInvalidMessage)
+	}
+
+	reason := strings.Replace(clientInfo[0], ">CLIENT:", "", 1)
+	if reason == "" {
+		return "", 0, 0, fmt.Errorf("unable to parse client reason: %w", ErrEmptyClientReasons)
+	}
+
+	cid, err := strconv.ParseUint(clientInfo[1], 10, 64)
+	if err != nil {
+		return "", 0, 0, fmt.Errorf("unable to parse cid: %w", err)
+	}
+
+	kid := uint64(0)
+
+	if reason != "DISCONNECT" && reason != "ESTABLISHED" {
+		kid, err = strconv.ParseUint(clientInfo[2], 10, 64)
+		if err != nil {
+			return "", 0, 0, fmt.Errorf("unable to parse kid: %w", err)
+		}
+	}
+
+	return reason, cid, kid, nil
+}
+
+func isClientReason(line string) bool {
+	return strings.HasPrefix(line, ">CLIENT:CONNECT") ||
+		strings.HasPrefix(line, ">CLIENT:REAUTH") ||
+		strings.HasPrefix(line, ">CLIENT:DISCONNECT") ||
+		strings.HasPrefix(line, ">CLIENT:ESTABLISHED") ||
+		strings.HasPrefix(line, ">CLIENT:CR_RESPONSE")
+}
diff --git a/internal/openvpn/connection/errors.go b/internal/openvpn/connection/errors.go
new file mode 100644
index 0000000..331345b
--- /dev/null
+++ b/internal/openvpn/connection/errors.go
@@ -0,0 +1,10 @@
+package connection
+
+import (
+	"errors"
+)
+
+var (
+	ErrEmptyClientReasons = errors.New("empty client reason")
+	ErrInvalidMessage     = errors.New("message invalid")
+)
diff --git a/internal/openvpn/errors.go b/internal/openvpn/errors.go
index 7df9ee4..b220c1b 100644
--- a/internal/openvpn/errors.go
+++ b/internal/openvpn/errors.go
@@ -1,3 +1,13 @@
 package openvpn
 
-const ErrorSsoNotSupported = "OpenVPN Client does not support SSO authentication via webauth"
+import (
+	"errors"
+)
+
+var (
+	ErrTimeout         = errors.New("timeout")
+	ErrEmptyResponse   = errors.New("empty response")
+	ErrUnknownProtocol = errors.New("unknown protocol")
+	ErrInvalidPassword = errors.New("invalid password")
+	ErrErrorResponse   = errors.New("error response")
+)
diff --git a/internal/openvpn/handler.go b/internal/openvpn/handler.go
new file mode 100644
index 0000000..297c7e6
--- /dev/null
+++ b/internal/openvpn/handler.go
@@ -0,0 +1,93 @@
+package openvpn
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn/connection"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
+)
+
+// handlePassword enters the password on the OpenVPN management interface connection.
+func (c *Client) handlePassword() error {
+	if err := c.rawCommand(utils.StringConcat(c.conf.OpenVpn.Password, "\n")); err != nil {
+		return fmt.Errorf("error from password command: %w", err)
+	}
+
+	resp, err := c.readMessage()
+	if err != nil {
+		return fmt.Errorf("unable to read messge from buffer: %w", err)
+	}
+
+	if !strings.Contains(resp, "SUCCESS: password is correct") {
+		return fmt.Errorf("unable to connect to openvpn management interface: %w", ErrInvalidPassword)
+	}
+
+	return nil
+}
+
+// handleMessages handles all incoming messages and route messages to different channels.
+func (c *Client) handleMessages() {
+	defer close(c.commandResponseCh)
+	defer close(c.clientsCh)
+
+	for {
+		message, err := c.readMessage()
+		if err != nil {
+			c.errCh <- err
+
+			return
+		}
+
+		if strings.HasPrefix(message, ">CLIENT:") {
+			client, err := connection.NewClient(message)
+			if err != nil {
+				c.errCh <- err
+
+				return
+			}
+
+			c.clientsCh <- client
+		} else if strings.HasPrefix(message, "SUCCESS:") ||
+			strings.HasPrefix(message, "ERROR:") ||
+			strings.HasPrefix(message, "OpenVPN Version:") {
+			c.commandResponseCh <- message
+		}
+	}
+}
+
+// handlePassword receive new message from clientsCh and process them.
+func (c *Client) handleClients() {
+	var client connection.Client
+
+	for {
+		client = <-c.clientsCh
+		if client.Reason == "" {
+			return
+		}
+
+		if err := c.processClient(client); err != nil {
+			c.errCh <- err
+
+			return
+		}
+	}
+}
+
+// handleCommands receive new command from commandsCh and send them to OpenVPN management interface.
+func (c *Client) handleCommands() {
+	var command string
+
+	for {
+		command = <-c.commandsCh
+		if command == "" {
+			return
+		}
+
+		if err := c.rawCommand(command); err != nil {
+			c.errCh <- err
+
+			return
+		}
+	}
+}
diff --git a/internal/openvpn/main.go b/internal/openvpn/main.go
new file mode 100644
index 0000000..848efe1
--- /dev/null
+++ b/internal/openvpn/main.go
@@ -0,0 +1,279 @@
+package openvpn
+
+import (
+	"bufio"
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"log/slog"
+	"net"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn/connection"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
+)
+
+func NewClient(logger *slog.Logger, conf config.Config) *Client {
+	return &Client{
+		conf:   conf,
+		logger: logger,
+
+		closed: false,
+		mu:     sync.Mutex{},
+
+		errCh:             make(chan error, 1),
+		clientsCh:         make(chan connection.Client, 10),
+		commandResponseCh: make(chan string, 10),
+		commandsCh:        make(chan string, 10),
+		shutdownCh:        make(chan struct{}, 1),
+	}
+}
+
+func (c *Client) Connect() error {
+	var err error
+
+	c.logger.Info(utils.StringConcat("connect to openvpn management interface ", c.conf.OpenVpn.Addr.String()))
+
+	err = c.setupConnection()
+	if err != nil {
+		return fmt.Errorf("unable to connect to openvpn management interface %s: %w", c.conf.OpenVpn.Addr.String(), err)
+	}
+
+	defer c.conn.Close()
+	c.scanner = bufio.NewScanner(c.conn)
+	c.scanner.Split(bufio.ScanLines)
+
+	if c.conf.OpenVpn.Password != "" {
+		err = c.handlePassword()
+		if err != nil {
+			return err
+		}
+	}
+
+	go c.handleMessages()
+	go c.handleClients()
+	go c.handleCommands()
+
+	err = c.releaseManagementHold()
+	if err != nil {
+		return err
+	}
+
+	c.logger.Info("Connection to OpenVPN management interfaced established.")
+
+	err = c.checkManagementInterfaceVersion()
+	if err != nil {
+		return err
+	}
+
+	for {
+		select {
+		case err := <-c.errCh:
+			c.close()
+
+			if err != nil {
+				return fmt.Errorf("OpenVPN management error: %w", err)
+			}
+
+			return nil
+		case <-c.shutdownCh:
+			c.close()
+
+			return nil
+		}
+	}
+}
+
+func (c *Client) setupConnection() error {
+	var err error
+
+	switch c.conf.OpenVpn.Addr.Scheme {
+	case "tcp":
+		c.conn, err = net.Dial(c.conf.OpenVpn.Addr.Scheme, c.conf.OpenVpn.Addr.Host)
+	case "unix":
+		c.conn, err = net.Dial(c.conf.OpenVpn.Addr.Scheme, c.conf.OpenVpn.Addr.Path)
+	default:
+		err = fmt.Errorf("unable to connect to openvpn management interface: %w %s", ErrUnknownProtocol, c.conf.OpenVpn.Addr.Scheme)
+	}
+
+	return err
+}
+
+func (c *Client) releaseManagementHold() error {
+	resp, err := c.SendCommand("hold release")
+	if err != nil {
+		return fmt.Errorf("error from hold release command: %w", err)
+	}
+
+	if !strings.HasPrefix(resp, "SUCCESS:") {
+		return fmt.Errorf("error from hold release command: %w: %s", ErrErrorResponse, resp)
+	}
+
+	return nil
+}
+
+func (c *Client) checkManagementInterfaceVersion() error {
+	resp, err := c.SendCommand("version")
+	if err != nil {
+		return fmt.Errorf("error from version command: %w", err)
+	}
+
+	if !strings.HasPrefix(resp, "OpenVPN Version: ") {
+		return fmt.Errorf("error from version command: %w: %s", ErrErrorResponse, resp)
+	}
+
+	versionParts := strings.Split(resp, "\n")
+
+	if len(versionParts) != 4 {
+		return fmt.Errorf("unexpected response from version command: %s", resp)
+	}
+
+	c.logger.Info(utils.StringConcat(versionParts[0], " - ", versionParts[1]))
+
+	managementInterfaceVersion, err := strconv.Atoi(versionParts[1][len(versionParts[1])-1:])
+	if err != nil {
+		return fmt.Errorf("unable to parse openvpn management interface version: %w", err)
+	}
+
+	if managementInterfaceVersion < 5 {
+		return errors.New("openvpn-auth-oauth2 requires OpenVPN management interface version 5 or higher")
+	}
+
+	return nil
+}
+
+func (c *Client) checkClientSsoCapabilities(logger *slog.Logger, client connection.Client) bool {
+	if val, ok := client.Env["IV_SSO"]; ok && strings.Contains(val, "webauth") {
+		return true
+	}
+
+	errorSsoNotSupported := "OpenVPN Client does not support SSO authentication via webauth"
+	logger.Warn(errorSsoNotSupported)
+
+	_, err := c.SendCommandf(`client-deny %d %d "%s" "%s"`,
+		client.Cid, client.Kid, errorSsoNotSupported, errorSsoNotSupported)
+	if err != nil {
+		c.logger.Warn(err.Error())
+	}
+
+	return false
+}
+
+// Shutdown shutdowns the client connection.
+func (c *Client) Shutdown() {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	if !c.closed {
+		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(cmd string) (string, error) {
+	c.commandsCh <- utils.StringConcat(cmd, "\n")
+
+	select {
+	case resp := <-c.commandResponseCh:
+		if resp == "" {
+			cmdFirstLine := strings.SplitN(cmd, "\n", 2)[0]
+
+			return "", fmt.Errorf("command error '%s': %w", cmdFirstLine, ErrEmptyResponse)
+		}
+
+		if strings.HasPrefix(resp, "ERROR:") {
+			cmdFirstLine := strings.SplitN(cmd, "\n", 2)[0]
+			c.logger.Warn(fmt.Sprintf("command error '%s': %s", cmdFirstLine, resp))
+		}
+
+		return resp, nil
+	case <-time.After(10 * time.Second):
+		cmdFirstLine := strings.SplitN(cmd, "\n", 2)[0]
+
+		return "", fmt.Errorf("command error '%s': %w", cmdFirstLine, ErrTimeout)
+	}
+}
+
+// SendCommandf passes command to a given connection (adds logging and EOL character) and returns the response.
+func (c *Client) SendCommandf(format string, a ...any) (string, error) {
+	return c.SendCommand(fmt.Sprintf(format, a...))
+}
+
+// rawCommand passes command to a given connection (adds logging and EOL character).
+func (c *Client) rawCommand(cmd string) error {
+	if c.logger.Enabled(context.Background(), slog.LevelDebug) {
+		c.logger.Debug(cmd)
+	}
+
+	_, err := c.conn.Write([]byte(cmd))
+	if err != nil {
+		return fmt.Errorf("unable to write into OpenVPN management connection: %w", err)
+	}
+
+	return nil
+}
+
+// readMessage .
+func (c *Client) readMessage() (string, error) {
+	var (
+		buf  bytes.Buffer
+		line []byte
+	)
+
+	for {
+		if ok := c.scanner.Scan(); !ok {
+			if c.scanner.Err() != nil {
+				return "", fmt.Errorf("readMessage: scanner error: %w", c.scanner.Err())
+			}
+
+			return "", nil
+		}
+
+		line = c.scanner.Bytes()
+
+		if _, err := buf.Write(line); err != nil {
+			return "", fmt.Errorf("readMessage: unable to write string to buffer: %w", err)
+		}
+
+		if _, err := buf.WriteString("\n"); err != nil {
+			return "", fmt.Errorf("readMessage: unable to write newline to buffer: %w", err)
+		}
+
+		if c.isMessageLineEOF(line) {
+			message := buf.String()
+			if c.logger.Enabled(context.Background(), slog.LevelDebug) {
+				c.logger.Debug(message)
+			}
+
+			return message, nil
+		}
+	}
+}
+
+func (c *Client) isMessageLineEOF(line []byte) bool {
+	return bytes.HasPrefix(line, []byte(">CLIENT:ENV,END")) ||
+		bytes.Index(line, []byte("END")) == 0 ||
+		bytes.Index(line, []byte("SUCCESS:")) == 0 ||
+		bytes.Index(line, []byte("ERROR:")) == 0 ||
+		bytes.HasPrefix(line, []byte(">HOLD:")) ||
+		bytes.HasPrefix(line, []byte(">INFO:")) ||
+		bytes.HasPrefix(line, []byte(">NOTIFY:")) ||
+		bytes.HasPrefix(line, []byte("ENTER PASSWORD:"))
+}
+
+func (c *Client) close() {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	if !c.closed {
+		c.closed = true
+		_ = c.conn.Close()
+		close(c.commandsCh)
+	}
+}
diff --git a/internal/openvpn/types.go b/internal/openvpn/types.go
new file mode 100644
index 0000000..b981f09
--- /dev/null
+++ b/internal/openvpn/types.go
@@ -0,0 +1,27 @@
+package openvpn
+
+import (
+	"bufio"
+	"log/slog"
+	"net"
+	"sync"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn/connection"
+)
+
+type Client struct {
+	conf    config.Config
+	conn    net.Conn
+	scanner *bufio.Scanner
+	logger  *slog.Logger
+
+	mu     sync.Mutex
+	closed bool
+
+	clientsCh         chan connection.Client
+	commandResponseCh chan string
+	commandsCh        chan string
+	errCh             chan error
+	shutdownCh        chan struct{}
+}
diff --git a/internal/state/errors.go b/internal/state/errors.go
new file mode 100644
index 0000000..9d44c70
--- /dev/null
+++ b/internal/state/errors.go
@@ -0,0 +1,5 @@
+package state
+
+import "errors"
+
+var ErrInvalid = errors.New("invalid state")
diff --git a/internal/state/state.go b/internal/state/state.go
index 2552782..a800ce5 100644
--- a/internal/state/state.go
+++ b/internal/state/state.go
@@ -2,10 +2,9 @@ package state
 
 import (
 	"encoding/json"
-	"errors"
+	"fmt"
 	"time"
 
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
 	"github.com/zitadel/oidc/v2/pkg/crypto"
 )
 
@@ -13,10 +12,10 @@ type State struct {
 	Cid        uint64    `json:"cid"`
 	Kid        uint64    `json:"kid"`
 	Ipaddr     string    `json:"ipaddr"`
-	CommonName string    `json:"common_name"`
+	CommonName string    `json:"commonName"`
 	Issued     time.Time `json:"issued"`
 
-	Encoded string
+	encoded string
 }
 
 func New(cid, kid uint64, ipaddr, commonName string) *State {
@@ -29,28 +28,32 @@ func New(cid, kid uint64, ipaddr, commonName string) *State {
 	}
 }
 
-func NewEncoded(state string) *State {
-	return &State{
-		Encoded: state,
+func NewEncoded(state string) State {
+	return State{
+		encoded: state,
 	}
 }
 
+func (state *State) Encoded() string {
+	return state.encoded
+}
+
 func (state *State) Decode(secretKey string) error {
-	jsonState, err := crypto.DecryptAES(state.Encoded, secretKey)
+	jsonState, err := crypto.DecryptAES(state.encoded, secretKey)
 	if err != nil {
-		return errors.New(utils.StringConcat("invalid state: ", state.Encoded, ": ", err.Error()))
+		return fmt.Errorf("invalid state %s: %w", state.encoded, err)
 	}
 
 	if err := json.Unmarshal([]byte(jsonState), &state); err != nil {
-		return err
+		return fmt.Errorf("json decode: %w", err)
 	}
 
 	issuedSince := time.Since(state.Issued)
 
 	if issuedSince >= time.Minute*2 {
-		return errors.New(utils.StringConcat("state expired after 2 minutes, issued at: ", state.Issued.String()))
+		return fmt.Errorf("%w: expired after 2 minutes, issued at: %s", ErrInvalid, state.Issued.String())
 	} else if issuedSince <= time.Second*-5 {
-		return errors.New(utils.StringConcat("state issued in future, issued at: ", state.Issued.String()))
+		return fmt.Errorf("%w: issued in future, issued at: %s", ErrInvalid, state.Issued.String())
 	}
 
 	return nil
@@ -59,12 +62,12 @@ func (state *State) Decode(secretKey string) error {
 func (state *State) Encode(secretKey string) error {
 	jsonState, err := json.Marshal(state)
 	if err != nil {
-		return err
+		return fmt.Errorf("json encode: %w", err)
 	}
 
-	state.Encoded, err = crypto.EncryptAES(string(jsonState), secretKey)
+	state.encoded, err = crypto.EncryptAES(string(jsonState), secretKey)
 	if err != nil {
-		return err
+		return fmt.Errorf("encrypt aes: %w", err)
 	}
 
 	return nil
diff --git a/internal/utils/slices.go b/internal/utils/slices.go
index a89249c..aa39812 100644
--- a/internal/utils/slices.go
+++ b/internal/utils/slices.go
@@ -1,10 +1,26 @@
 package utils
 
-func CastToSlice[T any](tokenGroups any) []T {
-	tokenGroupsInterfaceList := tokenGroups.([]any)
+import (
+	"errors"
+	"fmt"
+)
+
+var ErrCast = errors.New("unable to cast")
+
+func CastToSlice[T any](tokenGroups any) ([]T, error) {
+	tokenGroupsInterfaceList, ok := tokenGroups.([]any)
+	if !ok {
+		return nil, fmt.Errorf("%w input to []any", ErrCast)
+	}
+
 	tokenGroupsList := make([]T, len(tokenGroupsInterfaceList))
+
 	for i, v := range tokenGroupsInterfaceList {
-		tokenGroupsList[i] = v.(T)
+		tokenGroupsList[i], ok = v.(T)
+		if !ok {
+			return nil, fmt.Errorf("%w element", ErrCast)
+		}
 	}
-	return tokenGroupsList
+
+	return tokenGroupsList, nil
 }
diff --git a/internal/utils/strings.go b/internal/utils/strings.go
index fc6c322..30a1bf4 100644
--- a/internal/utils/strings.go
+++ b/internal/utils/strings.go
@@ -7,5 +7,6 @@ func StringConcat(strs ...string) string {
 	for _, str := range strs {
 		sb.WriteString(str)
 	}
+
 	return sb.String()
 }
diff --git a/internal/utils/url.go b/internal/utils/url.go
index 0ccace4..214599b 100644
--- a/internal/utils/url.go
+++ b/internal/utils/url.go
@@ -2,6 +2,6 @@ package utils
 
 import "net/url"
 
-func IsUrlEmpty(url *url.URL) bool {
+func IsURLEmpty(url *url.URL) bool {
 	return url == nil || url.String() == ""
 }
diff --git a/main.go b/main.go
index 7f82a59..cb99883 100644
--- a/main.go
+++ b/main.go
@@ -6,6 +6,7 @@ import (
 	"github.com/jkroepke/openvpn-auth-oauth2/cmd"
 )
 
+//nolint:gochecknoglobals
 var (
 	version = "dev"
 	commit  = "none"
@@ -13,5 +14,5 @@ var (
 )
 
 func main() {
-	os.Exit(cmd.Execute(version, commit, date, os.Stdout))
+	os.Exit(cmd.Execute(os.Args, os.Stdout, version, commit, date))
 }


Test patch:
diff --git a/cmd/root_test.go b/cmd/root_test.go
index d4cafd1..1f56c4f 100644
--- a/cmd/root_test.go
+++ b/cmd/root_test.go
@@ -1,4 +1,4 @@
-package cmd
+package cmd_test
 
 import (
 	"bufio"
@@ -14,6 +14,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/jkroepke/openvpn-auth-oauth2/cmd"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
 	"github.com/stretchr/testify/assert"
 	"github.com/zitadel/oidc/v2/example/server/storage"
@@ -22,26 +23,56 @@ import (
 )
 
 func TestExecuteVersion(t *testing.T) {
-	os.Args = []string{"openvpn-auth-oauth2", "--version"}
+	t.Parallel()
 
 	var buf bytes.Buffer
 	_ = io.Writer(&buf)
 
-	returnCode := Execute("version", "commit", "date", &buf)
+	returnCode := cmd.Execute([]string{"", "--version"}, &buf, "version", "commit", "date")
 	assert.Equal(t, 0, returnCode, buf.String())
 }
 
-func TestExecuteConfigFileNotFound(t *testing.T) {
-	os.Args = []string{"openvpn-auth-oauth2", "--config", "nonexists"}
+func TestExecuteConfigInvalid(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name string
+		args []string
+		err  string
+	}{
+		{
+			"file not exists",
+			[]string{"", "--config=nonexists"},
+			"error loading config: error from file provider: open nonexists: no such file or directory",
+		},
+		{
+			"invalid log format",
+			[]string{"", "--config=../config.example.yaml", "--log.format=invalid", "--log.level=warn", "--http.secret=1234567891011213"},
+			"error configure logging: unknown log format: invalid",
+		},
+		{
+			"invalid log level",
+			[]string{"", "--config=../config.example.yaml", "--log.format=console", "--log.level=invalid", "--http.secret=1234567891111213"},
+			`error configure logging: unable to parse log level: slog: level string \"invalid\": unknown name`,
+		},
+	}
 
-	var buf bytes.Buffer
-	_ = io.Writer(&buf)
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			var buf bytes.Buffer
+			_ = io.Writer(&buf)
 
-	returnCode := Execute("version", "commit", "date", &buf)
-	assert.Equal(t, 1, returnCode, buf.String())
+			returnCode := cmd.Execute(tt.args, &buf, "version", "commit", "date")
+			assert.Equal(t, 1, returnCode, buf.String())
+			assert.Contains(t, buf.String(), tt.err)
+		})
+	}
 }
 
-func TestExecuteConfigFileFound(t *testing.T) {
+func TestExecuteConfigFileFound(t *testing.T) { //nolint: paralleltest
 	opStorage := storage.NewStorage(storage.NewUserStore("http://localhost/"))
 	opConfig := &op.Config{
 		CryptoKey:                sha256.Sum256([]byte("test")),
@@ -59,17 +90,19 @@ func TestExecuteConfigFileFound(t *testing.T) {
 	)
 
 	assert.NoError(t, err)
+
 	svr := httptest.NewServer(handler.HttpHandler())
 
 	l, err := net.Listen("tcp", "127.0.0.1:0")
 	assert.NoError(t, err)
+
 	defer l.Close()
 
 	go func() {
 		conn, err := l.Accept()
 		assert.NoError(t, err)
 
-		defer conn.Close() //nolint:errcheck
+		defer conn.Close()
 		reader := bufio.NewReader(conn)
 
 		sendLine(t, conn, ">INFO:OpenVPN Management Interface Version 5 -- type 'help' for more info\r\n")
@@ -77,7 +110,7 @@ func TestExecuteConfigFileFound(t *testing.T) {
 		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, "OpenVPN Version: OpenVPN Mock\r\nManagement Interface Version: 5\r\nEND\r\n")
 
 		time.Sleep(100 * time.Millisecond)
 
@@ -85,32 +118,41 @@ func TestExecuteConfigFileFound(t *testing.T) {
 		if err != nil {
 			panic(err)
 		}
+
 		_ = p.Signal(syscall.SIGINT)
 	}()
 
-	os.Args = []string{
+	t.Setenv("CONFIG_OPENVPN_ADDR", utils.StringConcat(l.Addr().Network(), "://", l.Addr().String()))
+	t.Setenv("CONFIG_LOG_FORMAT", "console")
+	t.Setenv("CONFIG_LOG_LEVEL", "warn")
+
+	args := []string{
 		"openvpn-auth-oauth2",
 		"--config=../config.example.yaml",
 		"--http.secret=0123456789101112",
 		"--http.listen=127.0.0.1:0",
 		"--oauth2.issuer", svr.URL,
-		"--openvpn.addr", utils.StringConcat(l.Addr().Network(), "://", l.Addr().String()),
 	}
 
 	var buf bytes.Buffer
 	_ = io.Writer(&buf)
 
-	returnCode := Execute("version", "commit", "date", &buf)
+	returnCode := cmd.Execute(args, &buf, "version", "commit", "date")
 	assert.Equal(t, 0, returnCode, buf.String())
 }
 
-func sendLine(t testing.TB, conn net.Conn, msg string, a ...any) {
+func sendLine(tb testing.TB, conn net.Conn, msg string, a ...any) {
+	tb.Helper()
+
 	_, err := fmt.Fprintf(conn, msg, a...)
-	assert.NoError(t, err)
+	assert.NoError(tb, err)
 }
 
-func readLine(t testing.TB, reader *bufio.Reader) (msg string) {
+func readLine(tb testing.TB, reader *bufio.Reader) string {
+	tb.Helper()
+
 	line, err := reader.ReadString('\n')
-	assert.NoError(t, err)
+	assert.NoError(tb, err)
+
 	return strings.TrimSpace(line)
 }
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 5af7a10..5c2a590 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -1,13 +1,16 @@
-package config
+package config_test
 
 import (
 	"net/url"
 	"testing"
 
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
 	"github.com/stretchr/testify/assert"
 )
 
 func TestFlagSet(t *testing.T) {
+	t.Parallel()
+
 	for _, tt := range []struct {
 		name       string
 		args       []string
@@ -28,8 +31,11 @@ func TestFlagSet(t *testing.T) {
 			},
 		},
 	} {
+		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
-			flagSet := FlagSet()
+			t.Parallel()
+
+			flagSet := config.FlagSet()
 			err := flagSet.Parse(append([]string{"openvpn-auth-oauth2"}, tt.args...))
 			assert.NoError(t, err)
 			for arg, expected := range tt.expectArgs {
@@ -49,45 +55,46 @@ func TestFlagSet(t *testing.T) {
 				}
 			}
 		})
-
 	}
 }
+
 func TestValidate(t *testing.T) {
+	t.Parallel()
+
 	for _, tt := range []struct {
-		name   string
-		config *Config
-		err    string
+		name string
+		conf config.Config
+		err  string
 	}{
-		{"", &Config{}, "-"},
-		{"", &Config{Http: &Http{}}, "-"},
-		{"", &Config{Http: &Http{}, Oauth2: &OAuth2{}}, "-"},
-		{"", &Config{Http: &Http{}, Oauth2: &OAuth2{}, OpenVpn: &OpenVpn{}}, "-"},
-		{"", &Config{Http: &Http{}, Oauth2: &OAuth2{}, OpenVpn: &OpenVpn{}, Log: &Log{}}, "-"},
-		{"", &Config{Http: &Http{}, Oauth2: &OAuth2{Client: &OAuth2Client{}}, OpenVpn: &OpenVpn{}, Log: &Log{}}, "-"},
-		{"", &Config{Http: &Http{}, Oauth2: &OAuth2{Client: &OAuth2Client{}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{}, Log: &Log{}}, "-"},
-		{"", &Config{Http: &Http{}, Oauth2: &OAuth2{Client: &OAuth2Client{}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "-"},
-		{"", &Config{Http: &Http{BaseUrl: &url.URL{}}, Oauth2: &OAuth2{Client: &OAuth2Client{}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "-"},
-		{"", &Config{Http: &Http{BaseUrl: &url.URL{Scheme: "http", Host: "invalid"}}, Oauth2: &OAuth2{Client: &OAuth2Client{}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "-"},
-		{"", &Config{Http: &Http{BaseUrl: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "invalid"}, Oauth2: &OAuth2{Issuer: &url.URL{}, Client: &OAuth2Client{}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "-"},
-		{"", &Config{Http: &Http{BaseUrl: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "invalid"}, Oauth2: &OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: &OAuth2Client{}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "-"},
-		{"", &Config{Http: &Http{BaseUrl: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "invalid"}, Oauth2: &OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "http.check is nil"},
-		{"", &Config{Http: &Http{BaseUrl: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "invalid", Check: &HttpCheck{}}, Oauth2: &OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "http.secret requires a length of 16, 24 or 32"},
-		{"", &Config{Http: &Http{BaseUrl: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "invalid", Check: &HttpCheck{}}, Oauth2: &OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "http.secret requires a length of 16, 24 or 32"},
-		{"", &Config{Http: &Http{BaseUrl: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "0123456789101112", Check: &HttpCheck{}}, Oauth2: &OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Addr: &url.URL{}, Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "openvpn.addr: invalid URL. only tcp://addr or unix://addr scheme supported"},
-		{"", &Config{Http: &Http{BaseUrl: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "0123456789101112", Check: &HttpCheck{}}, Oauth2: &OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Addr: &url.URL{Scheme: "tcps", Host: "127.0.0.1:9000"}, Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "openvpn.addr: invalid URL. only tcp://addr or unix://addr scheme supported"},
-		{"", &Config{Http: &Http{BaseUrl: &url.URL{Scheme: "httpss", Host: "invalid"}, Secret: "0123456789101112", Check: &HttpCheck{}}, Oauth2: &OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Addr: &url.URL{Scheme: "tcp", Host: "127.0.0.1:9000"}, Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "-"},
-		{"", &Config{Http: &Http{BaseUrl: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "0123456789101112", Check: &HttpCheck{}}, Oauth2: &OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Addr: &url.URL{Scheme: "tcp", Host: "127.0.0.1:9000"}, Bypass: &OpenVpnBypass{}}, Log: &Log{}}, ""},
-		{"", &Config{Http: &Http{BaseUrl: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "0123456789101112", Check: &HttpCheck{}}, Oauth2: &OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{Token: &url.URL{}}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Addr: &url.URL{Scheme: "tcp", Host: "127.0.0.1:9000"}, Bypass: &OpenVpnBypass{}}, Log: &Log{}}, ""},
-		{"", &Config{Http: &Http{BaseUrl: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "0123456789101112", Check: &HttpCheck{}}, Oauth2: &OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{Token: &url.URL{Scheme: "http", Host: "invalid"}, Auth: &url.URL{Scheme: "http", Host: "invalid"}}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Addr: &url.URL{Scheme: "tcp", Host: "127.0.0.1:9000"}, Bypass: &OpenVpnBypass{}}, Log: &Log{}}, ""},
+		{"", config.Config{}, "-"},
+		{"", config.Config{HTTP: config.HTTP{}}, "-"},
+		{"", config.Config{HTTP: config.HTTP{}, OAuth2: config.OAuth2{}}, "-"},
+		{"", config.Config{HTTP: config.HTTP{}, OAuth2: config.OAuth2{}, OpenVpn: config.OpenVpn{}}, "-"},
+		{"", config.Config{HTTP: config.HTTP{}, OAuth2: config.OAuth2{}, OpenVpn: config.OpenVpn{}, Log: config.Log{}}, "-"},
+		{"", config.Config{HTTP: config.HTTP{}, OAuth2: config.OAuth2{Client: config.OAuth2Client{}}, OpenVpn: config.OpenVpn{}, Log: config.Log{}}, "-"},
+		{"", config.Config{HTTP: config.HTTP{}, OAuth2: config.OAuth2{Client: config.OAuth2Client{}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{}, Log: config.Log{}}, "-"},
+		{"", config.Config{HTTP: config.HTTP{}, OAuth2: config.OAuth2{Client: config.OAuth2Client{}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "-"},
+		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{}}, OAuth2: config.OAuth2{Client: config.OAuth2Client{}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "-"},
+		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}}, OAuth2: config.OAuth2{Client: config.OAuth2Client{}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "-"},
+		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "invalid"}, OAuth2: config.OAuth2{Issuer: &url.URL{}, Client: config.OAuth2Client{}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "-"},
+		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "invalid"}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "-"},
+		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "invalid", Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "http.secret requires a length of 16, 24 or 32"},
+		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "invalid", Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "http.secret requires a length of 16, 24 or 32"},
+		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "0123456789101112", Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Addr: &url.URL{}, Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "openvpn.addr: invalid URL. only tcp://addr or unix://addr scheme supported"},
+		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "0123456789101112", Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Addr: &url.URL{Scheme: "tcps", Host: "127.0.0.1:9000"}, Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "openvpn.addr: invalid URL. only tcp://addr or unix://addr scheme supported"},
+		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "httpss", Host: "invalid"}, Secret: "0123456789101112", Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Addr: &url.URL{Scheme: "tcp", Host: "127.0.0.1:9000"}, Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "-"},
+		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "0123456789101112", Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Addr: &url.URL{Scheme: "tcp", Host: "127.0.0.1:9000"}, Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, ""},
+		{"", config.Config{HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "invalid"}, Secret: "0123456789101112", Check: config.HTTPCheck{}}, OAuth2: config.OAuth2{Issuer: &url.URL{Scheme: "http", Host: "invalid"}, Client: config.OAuth2Client{ID: "client"}, Endpoints: config.OAuth2Endpoints{Token: &url.URL{Scheme: "http", Host: "invalid"}, Auth: &url.URL{Scheme: "http", Host: "invalid"}}, Validate: config.OAuth2Validate{}}, OpenVpn: config.OpenVpn{Addr: &url.URL{Scheme: "tcp", Host: "127.0.0.1:9000"}, Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, ""},
 	} {
+		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
-			err := Validate(tt.config)
+			t.Parallel()
+
+			err := config.Validate(tt.conf)
 			if tt.err == "" {
 				assert.NoError(t, err)
-			} else {
-				if assert.Error(t, err) && tt.err != "-" {
-					assert.Equal(t, err.Error(), tt.err)
-				}
+			} else if assert.Error(t, err) && tt.err != "-" {
+				assert.Equal(t, err.Error(), tt.err)
 			}
 		})
 	}
diff --git a/internal/oauth2/handler_test.go b/internal/oauth2/handler_test.go
index ff9fa2f..a9ec749 100644
--- a/internal/oauth2/handler_test.go
+++ b/internal/oauth2/handler_test.go
@@ -1,7 +1,8 @@
-package oauth2
+package oauth2_test
 
 import (
 	"bufio"
+	"context"
 	"crypto/sha256"
 	"fmt"
 	"io"
@@ -17,6 +18,7 @@ import (
 	"time"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
 	"github.com/stretchr/testify/assert"
@@ -25,39 +27,42 @@ import (
 	"golang.org/x/text/language"
 )
 
+var mu = sync.Mutex{} //nolint:gochecknoglobals
+
 func TestHandler(t *testing.T) {
+	t.Parallel()
+
 	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
 
-	confs := []struct {
+	tests := []struct {
 		name          string
-		conf          *config.Config
+		conf          config.Config
 		ipaddr        string
 		xForwardedFor string
 		allow         bool
 	}{
 		{
 			"default",
-			&config.Config{
-				Http: &config.Http{
+			config.Config{
+				HTTP: config.HTTP{
 					Secret: "0123456789101112",
-					Check: &config.HttpCheck{
-						IpAddr: false,
+					Check: config.HTTPCheck{
+						IPAddr: false,
 					},
 				},
-				Oauth2: &config.OAuth2{
+				OAuth2: config.OAuth2{
 					Provider:  "generic",
-					Client:    &config.OAuth2Client{Id: "ID", Secret: "SECRET"},
-					Endpoints: &config.OAuth2Endpoints{},
+					Endpoints: config.OAuth2Endpoints{},
 					Scopes:    []string{"openid", "profile"},
-					Validate: &config.OAuth2Validate{
+					Validate: config.OAuth2Validate{
 						Groups: make([]string, 0),
 						Roles:  make([]string, 0),
 						Issuer: true,
-						IpAddr: false,
+						IPAddr: false,
 					},
 				},
-				OpenVpn: &config.OpenVpn{
-					Bypass:        &config.OpenVpnBypass{CommonNames: []string{}},
+				OpenVpn: config.OpenVpn{
+					Bypass:        config.OpenVpnBypass{CommonNames: []string{}},
 					AuthTokenUser: true,
 				},
 			},
@@ -67,27 +72,26 @@ func TestHandler(t *testing.T) {
 		},
 		{
 			"with ipaddr",
-			&config.Config{
-				Http: &config.Http{
+			config.Config{
+				HTTP: config.HTTP{
 					Secret: "0123456789101112",
-					Check: &config.HttpCheck{
-						IpAddr: true,
+					Check: config.HTTPCheck{
+						IPAddr: true,
 					},
 				},
-				Oauth2: &config.OAuth2{
+				OAuth2: config.OAuth2{
 					Provider:  "generic",
-					Client:    &config.OAuth2Client{Id: "ID", Secret: "SECRET"},
-					Endpoints: &config.OAuth2Endpoints{},
+					Endpoints: config.OAuth2Endpoints{},
 					Scopes:    []string{"openid", "profile"},
-					Validate: &config.OAuth2Validate{
+					Validate: config.OAuth2Validate{
 						Groups: make([]string, 0),
 						Roles:  make([]string, 0),
 						Issuer: true,
-						IpAddr: false,
+						IPAddr: false,
 					},
 				},
-				OpenVpn: &config.OpenVpn{
-					Bypass:        &config.OpenVpnBypass{CommonNames: []string{}},
+				OpenVpn: config.OpenVpn{
+					Bypass:        config.OpenVpnBypass{CommonNames: []string{}},
 					AuthTokenUser: true,
 				},
 			},
@@ -97,28 +101,27 @@ func TestHandler(t *testing.T) {
 		},
 		{
 			"with ipaddr + forwarded-for",
-			&config.Config{
-				Http: &config.Http{
+			config.Config{
+				HTTP: config.HTTP{
 					Secret: "0123456789101112",
-					Check: &config.HttpCheck{
-						IpAddr: true,
+					Check: config.HTTPCheck{
+						IPAddr: true,
 					},
 					EnableProxyHeaders: true,
 				},
-				Oauth2: &config.OAuth2{
+				OAuth2: config.OAuth2{
 					Provider:  "generic",
-					Client:    &config.OAuth2Client{Id: "ID", Secret: "SECRET"},
-					Endpoints: &config.OAuth2Endpoints{},
+					Endpoints: config.OAuth2Endpoints{},
 					Scopes:    []string{"openid", "profile"},
-					Validate: &config.OAuth2Validate{
+					Validate: config.OAuth2Validate{
 						Groups: make([]string, 0),
 						Roles:  make([]string, 0),
 						Issuer: true,
-						IpAddr: false,
+						IPAddr: false,
 					},
 				},
-				OpenVpn: &config.OpenVpn{
-					Bypass:        &config.OpenVpnBypass{CommonNames: []string{}},
+				OpenVpn: config.OpenVpn{
+					Bypass:        config.OpenVpnBypass{CommonNames: []string{}},
 					AuthTokenUser: true,
 				},
 			},
@@ -128,28 +131,27 @@ func TestHandler(t *testing.T) {
 		},
 		{
 			"with ipaddr + disabled forwarded-for",
-			&config.Config{
-				Http: &config.Http{
+			config.Config{
+				HTTP: config.HTTP{
 					Secret: "0123456789101112",
-					Check: &config.HttpCheck{
-						IpAddr: true,
+					Check: config.HTTPCheck{
+						IPAddr: true,
 					},
 					EnableProxyHeaders: false,
 				},
-				Oauth2: &config.OAuth2{
+				OAuth2: config.OAuth2{
 					Provider:  "generic",
-					Client:    &config.OAuth2Client{Id: "ID", Secret: "SECRET"},
-					Endpoints: &config.OAuth2Endpoints{},
+					Endpoints: config.OAuth2Endpoints{},
 					Scopes:    []string{"openid", "profile"},
-					Validate: &config.OAuth2Validate{
+					Validate: config.OAuth2Validate{
 						Groups: make([]string, 0),
 						Roles:  make([]string, 0),
 						Issuer: true,
-						IpAddr: false,
+						IPAddr: false,
 					},
 				},
-				OpenVpn: &config.OpenVpn{
-					Bypass:        &config.OpenVpnBypass{CommonNames: []string{}},
+				OpenVpn: config.OpenVpn{
+					Bypass:        config.OpenVpnBypass{CommonNames: []string{}},
 					AuthTokenUser: true,
 				},
 			},
@@ -159,28 +161,27 @@ func TestHandler(t *testing.T) {
 		},
 		{
 			"with ipaddr + multiple forwarded-for",
-			&config.Config{
-				Http: &config.Http{
+			config.Config{
+				HTTP: config.HTTP{
 					Secret: "0123456789101112",
-					Check: &config.HttpCheck{
-						IpAddr: true,
+					Check: config.HTTPCheck{
+						IPAddr: true,
 					},
 					EnableProxyHeaders: true,
 				},
-				Oauth2: &config.OAuth2{
+				OAuth2: config.OAuth2{
 					Provider:  "generic",
-					Client:    &config.OAuth2Client{Id: "ID", Secret: "SECRET"},
-					Endpoints: &config.OAuth2Endpoints{},
+					Endpoints: config.OAuth2Endpoints{},
 					Scopes:    []string{"openid", "profile"},
-					Validate: &config.OAuth2Validate{
+					Validate: config.OAuth2Validate{
 						Groups: make([]string, 0),
 						Roles:  make([]string, 0),
 						Issuer: true,
-						IpAddr: false,
+						IPAddr: false,
 					},
 				},
-				OpenVpn: &config.OpenVpn{
-					Bypass:        &config.OpenVpnBypass{CommonNames: []string{}},
+				OpenVpn: config.OpenVpn{
+					Bypass:        config.OpenVpnBypass{CommonNames: []string{}},
 					AuthTokenUser: true,
 				},
 			},
@@ -190,9 +191,12 @@ func TestHandler(t *testing.T) {
 		},
 	}
 
-	for _, tt := range confs {
+	for _, tt := range tests {
 		tt := tt
+
 		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
 			managementInterface, err := net.Listen("tcp", "127.0.0.1:0")
 			assert.NoError(t, err)
 			defer managementInterface.Close()
@@ -201,26 +205,27 @@ func TestHandler(t *testing.T) {
 			assert.NoError(t, err)
 			defer clientListener.Close()
 
-			resourceServer, err := setupResourceServer(clientListener)
+			resourceServer, clientCredentials, err := setupResourceServer(clientListener)
 			assert.NoError(t, err)
 			defer resourceServer.Close()
 
-			resourceServerUrl, err := url.Parse(resourceServer.URL)
+			resourceServerURL, err := url.Parse(resourceServer.URL)
 			assert.NoError(t, err)
 
-			tt.conf.Oauth2.Issuer = resourceServerUrl
-			tt.conf.Http.BaseUrl = &url.URL{Scheme: "http", Host: clientListener.Addr().String()}
+			tt.conf.OAuth2.Client = clientCredentials
+			tt.conf.OAuth2.Issuer = resourceServerURL
+			tt.conf.HTTP.BaseURL = &url.URL{Scheme: "http", Host: clientListener.Addr().String()}
 			tt.conf.OpenVpn.Addr = &url.URL{Scheme: managementInterface.Addr().Network(), Host: managementInterface.Addr().String()}
 
 			client := openvpn.NewClient(logger, tt.conf)
 			defer client.Shutdown()
 
-			provider, err := NewProvider(logger, tt.conf)
+			provider, err := oauth2.NewProvider(logger, tt.conf)
 			if !assert.NoError(t, err) {
 				return
 			}
 
-			httpClientListener := httptest.NewUnstartedServer(Handler(logger, provider, tt.conf, client))
+			httpClientListener := httptest.NewUnstartedServer(oauth2.Handler(logger, provider, tt.conf, client))
 			httpClientListener.Listener.Close()
 			httpClientListener.Listener = clientListener
 			httpClientListener.Start()
@@ -238,7 +243,7 @@ func TestHandler(t *testing.T) {
 				conn, err := managementInterface.Accept()
 				assert.NoError(t, err)
 
-				defer conn.Close() //nolint:errcheck
+				defer conn.Close()
 				reader := bufio.NewReader(conn)
 
 				sendLine(t, conn, ">INFO:OpenVPN Management Interface Version 5 -- type 'help' for more info\r\n")
@@ -246,14 +251,14 @@ func TestHandler(t *testing.T) {
 				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, "OpenVPN Version: OpenVPN Mock\r\nManagement Interface Version: 5\r\nEND\r\n")
 
 				if tt.allow {
 					assert.Equal(t, "client-auth 0 1", readLine(t, reader))
 					assert.Equal(t, "push \"auth-token-user aWQx\"", readLine(t, reader))
 					assert.Equal(t, "END", readLine(t, reader))
 				} else {
-					assert.Equal(t, `client-deny 0 1 "client rejected"`, readLine(t, reader))
+					assert.Equal(t, `client-deny 0 1 "http client ip 127.0.0.1 and vpn ip 127.0.0.2 is different."`, readLine(t, reader))
 				}
 
 				sendLine(t, conn, "SUCCESS: client-auth command succeeded\r\n")
@@ -275,14 +280,18 @@ func TestHandler(t *testing.T) {
 			httpClient.Jar = jar
 
 			sessionState := state.New(0, 1, tt.ipaddr, "name")
-			err = sessionState.Encode(tt.conf.Http.Secret)
+			err = sessionState.Encode(tt.conf.HTTP.Secret)
 			if !assert.NoError(t, err) {
 				return
 			}
 
 			time.Sleep(time.Millisecond * 100)
 
-			request, err := http.NewRequest("GET", fmt.Sprintf("%s/oauth2/start?state=%s", httpClientListener.URL, sessionState.Encoded), nil)
+			request, err := http.NewRequestWithContext(context.Background(), http.MethodGet,
+				fmt.Sprintf("%s/oauth2/start?state=%s", httpClientListener.URL, sessionState.Encoded()),
+				nil,
+			)
+
 			if !assert.NoError(t, err) {
 				return
 			}
@@ -317,8 +326,11 @@ func TestHandler(t *testing.T) {
 	}
 }
 
-func setupResourceServer(clientListener net.Listener) (*httptest.Server, error) {
-	storage.RegisterClients(storage.WebClient("ID", "SECRET", fmt.Sprintf("http://%s/oauth2/callback", clientListener.Addr().String())))
+func setupResourceServer(clientListener net.Listener) (*httptest.Server, config.OAuth2Client, error) {
+	mu.Lock()
+	storage.RegisterClients(storage.WebClient(clientListener.Addr().String(), "SECRET", fmt.Sprintf("http://%s/oauth2/callback", clientListener.Addr().String())))
+	mu.Unlock()
+
 	opStorage := storage.NewStorage(storage.NewUserStore("http://localhost"))
 	opConfig := &op.Config{
 		CryptoKey:                sha256.Sum256([]byte("test")),
@@ -334,9 +346,8 @@ func setupResourceServer(clientListener net.Listener) (*httptest.Server, error)
 	opProvider, err := op.NewDynamicOpenIDProvider("", opConfig, opStorage,
 		op.WithAllowInsecure(),
 	)
-
 	if err != nil {
-		return nil, err
+		return nil, config.OAuth2Client{}, err
 	}
 
 	mux := http.NewServeMux()
@@ -346,16 +357,21 @@ func setupResourceServer(clientListener net.Listener) (*httptest.Server, error)
 		http.Redirect(w, r, op.AuthCallbackURL(opProvider)(r.Context(), r.FormValue("authRequestID")), http.StatusFound)
 	}))
 
-	return httptest.NewServer(mux), err
+	return httptest.NewServer(mux), config.OAuth2Client{ID: clientListener.Addr().String(), Secret: "SECRET"}, err
 }
 
-func sendLine(t *testing.T, conn net.Conn, msg string, a ...any) {
-	_, err := fmt.Fprintf(conn, msg, a...)
+func sendLine(t *testing.T, conn net.Conn, msg string) {
+	t.Helper()
+
+	_, err := fmt.Fprint(conn, msg)
 	assert.NoError(t, err)
 }
 
-func readLine(t *testing.T, reader *bufio.Reader) (msg string) {
+func readLine(t *testing.T, reader *bufio.Reader) string {
+	t.Helper()
+
 	line, err := reader.ReadString('\n')
 	assert.NoError(t, err)
+
 	return strings.TrimSpace(line)
 }
diff --git a/internal/oauth2/provider_test.go b/internal/oauth2/provider_test.go
index f196f9a..05cba17 100644
--- a/internal/oauth2/provider_test.go
+++ b/internal/oauth2/provider_test.go
@@ -1,4 +1,4 @@
-package oauth2
+package oauth2_test
 
 import (
 	"crypto/sha256"
@@ -9,16 +9,18 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/providers/generic"
 	"github.com/stretchr/testify/assert"
 	"github.com/zitadel/oidc/v2/example/server/storage"
 	"github.com/zitadel/oidc/v2/pkg/op"
 	"golang.org/x/text/language"
-
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
 )
 
 func TestNewProvider(t *testing.T) {
+	t.Parallel()
+
 	opStorage := storage.NewStorage(storage.NewUserStore("http://localhost/"))
 	opConfig := &op.Config{
 		CryptoKey:                sha256.Sum256([]byte("test")),
@@ -36,39 +38,40 @@ func TestNewProvider(t *testing.T) {
 	)
 
 	assert.NoError(t, err)
+
 	svr := httptest.NewServer(handler.HttpHandler())
 	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
 
-	svrUrl, _ := url.Parse(svr.URL)
+	svrURL, _ := url.Parse(svr.URL)
 
-	configs := []struct {
+	tests := []struct {
 		name   string
-		config *config.Config
+		config config.Config
 		err    string
 	}{
 		{
 			"default",
-			&config.Config{
-				Http: &config.Http{BaseUrl: &url.URL{Scheme: "http", Host: "localhost"}},
-				Oauth2: &config.OAuth2{
-					Issuer:    svrUrl,
+			config.Config{
+				HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "localhost"}},
+				OAuth2: config.OAuth2{
+					Issuer:    svrURL,
 					Provider:  generic.Name,
-					Client:    &config.OAuth2Client{Id: "ID", Secret: "ID"},
-					Endpoints: &config.OAuth2Endpoints{},
+					Client:    config.OAuth2Client{ID: "ID", Secret: "ID"},
+					Endpoints: config.OAuth2Endpoints{},
 				},
 			},
 			"",
 		},
 		{
 			"with custom discovery",
-			&config.Config{
-				Http: &config.Http{BaseUrl: &url.URL{Scheme: "http", Host: "localhost"}},
-				Oauth2: &config.OAuth2{
-					Issuer:   svrUrl,
+			config.Config{
+				HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "localhost"}},
+				OAuth2: config.OAuth2{
+					Issuer:   svrURL,
 					Provider: generic.Name,
-					Client:   &config.OAuth2Client{Id: "ID", Secret: "ID"},
-					Endpoints: &config.OAuth2Endpoints{
-						Discovery: &url.URL{Scheme: svrUrl.Scheme, Host: svrUrl.Host, Path: "/.well-known/openid-configuration"},
+					Client:   config.OAuth2Client{ID: "ID", Secret: "ID"},
+					Endpoints: config.OAuth2Endpoints{
+						Discovery: &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/.well-known/openid-configuration"},
 					},
 				},
 			},
@@ -76,31 +79,31 @@ func TestNewProvider(t *testing.T) {
 		},
 		{
 			"with invalid custom discovery",
-			&config.Config{
-				Http: &config.Http{BaseUrl: &url.URL{Scheme: "http", Host: "localhost"}},
-				Oauth2: &config.OAuth2{
-					Issuer:   svrUrl,
+			config.Config{
+				HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "localhost"}},
+				OAuth2: config.OAuth2{
+					Issuer:   svrURL,
 					Provider: generic.Name,
-					Client:   &config.OAuth2Client{Id: "ID", Secret: "ID"},
-					Endpoints: &config.OAuth2Endpoints{
-						Discovery: &url.URL{Scheme: svrUrl.Scheme, Host: svrUrl.Host, Path: "/.well-known/openid-config"},
+					Client:   config.OAuth2Client{ID: "ID", Secret: "ID"},
+					Endpoints: config.OAuth2Endpoints{
+						Discovery: &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/.well-known/openid-config"},
 					},
 				},
 			},
-			"http status not ok: 404 Not Found 404 page not found",
+			"newProviderWithDiscovery: http status not ok: 404 Not Found 404 page not found",
 		},
 		{
 			"with custom endpoints",
-			&config.Config{
-				Http: &config.Http{BaseUrl: &url.URL{Scheme: "http", Host: "localhost"}},
-				Oauth2: &config.OAuth2{
-					Issuer:   svrUrl,
+			config.Config{
+				HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "localhost"}},
+				OAuth2: config.OAuth2{
+					Issuer:   svrURL,
 					Provider: generic.Name,
-					Client:   &config.OAuth2Client{Id: "ID", Secret: "ID"},
-					Endpoints: &config.OAuth2Endpoints{
-						Discovery: &url.URL{Scheme: svrUrl.Scheme, Host: svrUrl.Host, Path: "/.well-known/openid-configuration"},
-						Auth:      &url.URL{Scheme: svrUrl.Scheme, Host: svrUrl.Host, Path: "/authorize"},
-						Token:     &url.URL{Scheme: svrUrl.Scheme, Host: svrUrl.Host, Path: "/token"},
+					Client:   config.OAuth2Client{ID: "ID", Secret: "ID"},
+					Endpoints: config.OAuth2Endpoints{
+						Discovery: &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/.well-known/openid-configuration"},
+						Auth:      &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/authorize"},
+						Token:     &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/token"},
 					},
 				},
 			},
@@ -108,58 +111,63 @@ func TestNewProvider(t *testing.T) {
 		},
 		{
 			"with missing custom endpoints",
-			&config.Config{
-				Http: &config.Http{BaseUrl: &url.URL{Scheme: "http", Host: "localhost"}},
-				Oauth2: &config.OAuth2{
-					Issuer:   svrUrl,
+			config.Config{
+				HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "localhost"}},
+				OAuth2: config.OAuth2{
+					Issuer:   svrURL,
 					Provider: generic.Name,
-					Client:   &config.OAuth2Client{Id: "ID", Secret: "ID"},
-					Endpoints: &config.OAuth2Endpoints{
-						Discovery: &url.URL{Scheme: svrUrl.Scheme, Host: svrUrl.Host, Path: "/.well-known/openid-configuration"},
-						Auth:      &url.URL{Scheme: svrUrl.Scheme, Host: svrUrl.Host, Path: "/authorize"},
+					Client:   config.OAuth2Client{ID: "ID", Secret: "ID"},
+					Endpoints: config.OAuth2Endpoints{
+						Discovery: &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/.well-known/openid-configuration"},
+						Auth:      &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/authorize"},
 					},
 				},
 			},
-			"both oauth2.endpoints.tokenUrl and oauth2.endpoints.authUrl are required",
+			"error getting endpoints: both oauth2.endpoints.tokenUrl and oauth2.endpoints.authUrl are required",
 		},
 		{
 			"with pkce",
-			&config.Config{
-				Http: &config.Http{BaseUrl: &url.URL{Scheme: "http", Host: "localhost"}},
-				Oauth2: &config.OAuth2{
-					Issuer:   svrUrl,
+			config.Config{
+				HTTP: config.HTTP{BaseURL: &url.URL{Scheme: "http", Host: "localhost"}},
+				OAuth2: config.OAuth2{
+					Issuer:   svrURL,
 					Provider: generic.Name,
 					Pkce:     true,
-					Client:   &config.OAuth2Client{Id: "ID", Secret: "ID"},
-					Endpoints: &config.OAuth2Endpoints{
-						Discovery: &url.URL{Scheme: svrUrl.Scheme, Host: svrUrl.Host, Path: "/.well-known/openid-configuration"},
-						Auth:      &url.URL{Scheme: svrUrl.Scheme, Host: svrUrl.Host, Path: "/authorize"},
-						Token:     &url.URL{Scheme: svrUrl.Scheme, Host: svrUrl.Host, Path: "/token"},
+					Client:   config.OAuth2Client{ID: "ID", Secret: "ID"},
+					Endpoints: config.OAuth2Endpoints{
+						Discovery: &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/.well-known/openid-configuration"},
+						Auth:      &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/authorize"},
+						Token:     &url.URL{Scheme: svrURL.Scheme, Host: svrURL.Host, Path: "/token"},
 					},
 				},
 			},
 			"",
 		},
 	}
-	for _, tt := range configs {
+	for _, tt := range tests {
+		tt := tt
+
 		t.Run(tt.name, func(t *testing.T) {
-			provider, err := NewProvider(logger, tt.config)
+			t.Parallel()
+
+			provider, err := oauth2.NewProvider(logger, tt.config)
 			if tt.err != "" && assert.Error(t, err) {
 				assert.Equal(t, strings.TrimSpace(err.Error()), tt.err)
+
 				return
 			}
 
 			assert.NoError(t, err)
 
-			assert.Equal(t, provider.OAuthConfig().ClientID, tt.config.Oauth2.Client.Id)
-			assert.Equal(t, provider.OAuthConfig().ClientSecret, tt.config.Oauth2.Client.Secret)
-			if tt.config.Oauth2.Endpoints.Auth != nil {
-				assert.Equal(t, provider.OAuthConfig().Endpoint.AuthURL, tt.config.Oauth2.Endpoints.Auth.String())
+			assert.Equal(t, provider.OAuthConfig().ClientID, tt.config.OAuth2.Client.ID)
+			assert.Equal(t, provider.OAuthConfig().ClientSecret, tt.config.OAuth2.Client.Secret)
+			if tt.config.OAuth2.Endpoints.Auth != nil {
+				assert.Equal(t, provider.OAuthConfig().Endpoint.AuthURL, tt.config.OAuth2.Endpoints.Auth.String())
 			} else {
 				assert.NotEmpty(t, provider.OAuthConfig().Endpoint.AuthURL)
 			}
-			if tt.config.Oauth2.Endpoints.Token != nil {
-				assert.Equal(t, provider.OAuthConfig().Endpoint.TokenURL, tt.config.Oauth2.Endpoints.Token.String())
+			if tt.config.OAuth2.Endpoints.Token != nil {
+				assert.Equal(t, provider.OAuthConfig().Endpoint.TokenURL, tt.config.OAuth2.Endpoints.Token.String())
 			} else {
 				assert.NotEmpty(t, provider.OAuthConfig().Endpoint.TokenURL)
 			}
diff --git a/internal/oauth2/providers/generic/check_test.go b/internal/oauth2/providers/generic/check_test.go
index aebaceb..a686a10 100644
--- a/internal/oauth2/providers/generic/check_test.go
+++ b/internal/oauth2/providers/generic/check_test.go
@@ -1,17 +1,19 @@
-package generic
+package generic_test
 
 import (
 	"context"
 	"testing"
 
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/providers/generic"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
 	"github.com/stretchr/testify/assert"
-
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
 	"github.com/zitadel/oidc/v2/pkg/oidc"
 )
 
 func TestCheckUser(t *testing.T) {
+	t.Parallel()
+
 	token := &oidc.Tokens[*oidc.IDTokenClaims]{
 		IDTokenClaims: &oidc.IDTokenClaims{
 			TokenClaims: oidc.TokenClaims{
@@ -24,22 +26,24 @@ func TestCheckUser(t *testing.T) {
 		},
 	}
 
-	conf := &config.Config{
-		Oauth2: &config.OAuth2{
-			Validate: &config.OAuth2Validate{},
+	conf := config.Config{
+		OAuth2: config.OAuth2{
+			Validate: config.OAuth2Validate{},
 		},
 	}
 
-	provider := NewProvider(conf)
+	provider := generic.NewProvider(conf)
 
 	userData, err := provider.GetUser(context.Background(), token)
 	assert.NoError(t, err)
 
-	err = provider.CheckUser(context.Background(), &state.State{}, userData, token)
+	err = provider.CheckUser(context.Background(), state.State{}, userData, token)
 	assert.NoError(t, err)
 }
 
 func TestValidateGroups(t *testing.T) {
+	t.Parallel()
+
 	for _, tt := range []struct {
 		name           string
 		tokenClaim     string
@@ -52,11 +56,15 @@ func TestValidateGroups(t *testing.T) {
 		{"groups empty", "groups", []any{}, []string{}, ""},
 		{"groups present", "groups", []any{"apple"}, []string{}, ""},
 		{"require one group", "groups", []any{"apple"}, []string{"apple"}, ""},
-		{"require one group, claim not present", "", []any{"apple"}, []string{"apple"}, "missing groups claim"},
-		{"require two group, missing one", "groups", []any{"apple"}, []string{"apple", "pear"}, "missing required group pear"},
+		{"require one group, claim not present", "", []any{"apple"}, []string{"apple"}, "missing claim: groups"},
+		{"require two group, missing one", "groups", []any{"apple"}, []string{"apple", "pear"}, "missing required group: pear"},
 		{"require two group", "groups", []any{"apple", "pear"}, []string{"apple", "pear"}, ""},
 	} {
+		tt := tt
+
 		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
 			token := &oidc.Tokens[*oidc.IDTokenClaims]{
 				IDTokenClaims: &oidc.IDTokenClaims{
 					Claims: map[string]any{
@@ -65,26 +73,28 @@ func TestValidateGroups(t *testing.T) {
 				},
 			}
 
-			conf := &config.Config{
-				Oauth2: &config.OAuth2{
-					Validate: &config.OAuth2Validate{
+			conf := config.Config{
+				OAuth2: config.OAuth2{
+					Validate: config.OAuth2Validate{
 						Groups: tt.requiredGroups,
 					},
 				},
 			}
 
-			err := NewProvider(conf).CheckGroups(token)
+			err := generic.NewProvider(conf).CheckGroups(token)
 
 			if tt.err == "" {
 				assert.NoError(t, err)
-			} else {
-				assert.Equal(t, err.Error(), tt.err)
+			} else if assert.Error(t, err) {
+				assert.Equal(t, tt.err, err.Error())
 			}
 		})
 	}
 }
 
 func TestValidateRoles(t *testing.T) {
+	t.Parallel()
+
 	for _, tt := range []struct {
 		name          string
 		tokenClaim    string
@@ -97,11 +107,15 @@ func TestValidateRoles(t *testing.T) {
 		{"groups empty", "roles", []any{}, []string{}, ""},
 		{"groups present", "roles", []any{"apple"}, []string{}, ""},
 		{"require one group", "roles", []any{"apple"}, []string{"apple"}, ""},
-		{"require one group, claim not present", "", []any{"apple"}, []string{"apple"}, "missing roles claim"},
-		{"require two group, missing one", "roles", []any{"apple"}, []string{"apple", "pear"}, "missing required role pear"},
+		{"require one group, claim not present", "", []any{"apple"}, []string{"apple"}, "missing claim: roles"},
+		{"require two group, missing one", "roles", []any{"apple"}, []string{"apple", "pear"}, "missing required role: pear"},
 		{"require two group", "roles", []any{"apple", "pear"}, []string{"apple", "pear"}, ""},
 	} {
+		tt := tt
+
 		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
 			token := &oidc.Tokens[*oidc.IDTokenClaims]{
 				IDTokenClaims: &oidc.IDTokenClaims{
 					Claims: map[string]any{
@@ -110,25 +124,27 @@ func TestValidateRoles(t *testing.T) {
 				},
 			}
 
-			conf := &config.Config{
-				Oauth2: &config.OAuth2{
-					Validate: &config.OAuth2Validate{
+			conf := config.Config{
+				OAuth2: config.OAuth2{
+					Validate: config.OAuth2Validate{
 						Roles: tt.requiredRoles,
 					},
 				},
 			}
 
-			err := NewProvider(conf).CheckRoles(token)
+			err := generic.NewProvider(conf).CheckRoles(token)
 			if tt.err == "" {
 				assert.NoError(t, err)
-			} else {
-				assert.Equal(t, err.Error(), tt.err)
+			} else if assert.Error(t, err) {
+				assert.Equal(t, tt.err, err.Error())
 			}
 		})
 	}
 }
 
 func TestValidateCommonName(t *testing.T) {
+	t.Parallel()
+
 	for _, tt := range []struct {
 		name               string
 		tokenClaim         string
@@ -140,9 +156,12 @@ func TestValidateCommonName(t *testing.T) {
 		{"sub present", "sub", "apple", "", "common_name mismatch: openvpn client: apple - oidc token: "},
 		{"sub required", "sub", "apple", "apple", ""},
 		{"sub required wrong", "sub", "pear", "apple", "common_name mismatch: openvpn client: pear - oidc token: apple"},
-		{"nonexists claim", "nonexists", "pear", "apple", "missing nonexists claim"},
+		{"nonexists claim", "nonexists", "pear", "apple", "missing claim: nonexists"},
 	} {
+		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
 			token := &oidc.Tokens[*oidc.IDTokenClaims]{
 				IDTokenClaims: &oidc.IDTokenClaims{
 					Claims: map[string]any{
@@ -151,69 +170,75 @@ func TestValidateCommonName(t *testing.T) {
 				},
 			}
 
-			conf := &config.Config{
-				Oauth2: &config.OAuth2{
-					Validate: &config.OAuth2Validate{
+			conf := config.Config{
+				OAuth2: config.OAuth2{
+					Validate: config.OAuth2Validate{
 						CommonName: tt.tokenClaim,
 					},
 				},
 			}
 
-			session := &state.State{
+			session := state.State{
 				CommonName: tt.requiredCommonName,
 			}
 
-			err := NewProvider(conf).CheckCommonName(session, token)
+			err := generic.NewProvider(conf).CheckCommonName(session, token)
 			if tt.err == "" {
 				assert.NoError(t, err)
-			} else {
-				assert.Equal(t, err.Error(), tt.err)
+			} else if assert.Error(t, err) {
+				assert.Equal(t, tt.err, err.Error())
 			}
 		})
 	}
 }
 
 func TestValidateIpAddr(t *testing.T) {
+	t.Parallel()
+
 	for _, tt := range []struct {
 		name           string
-		validateIpAddr bool
+		validateIPAddr bool
 		tokenClaim     string
-		tokenIpAddr    any
-		requiredIpAddr string
+		tokenIPAddr    any
+		requiredIPAddr string
 		err            string
 	}{
 		{"no require", false, "nonexists", "apple", "", ""},
 		{"ip present", true, "ipaddr", "apple", "", "ipaddr mismatch: openvpn client: apple - oidc token: "},
 		{"sub required", true, "ipaddr", "apple", "apple", ""},
 		{"sub required wrong", true, "ipaddr", "pear", "apple", "ipaddr mismatch: openvpn client: pear - oidc token: apple"},
-		{"nonexists claim", true, "nonexists", "pear", "apple", "missing ipaddr claim"},
+		{"nonexists claim", true, "nonexists", "pear", "apple", "missing claim: ipaddr"},
 	} {
+		tt := tt
+
 		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
 			token := &oidc.Tokens[*oidc.IDTokenClaims]{
 				IDTokenClaims: &oidc.IDTokenClaims{
 					Claims: map[string]any{
-						tt.tokenClaim: tt.tokenIpAddr,
+						tt.tokenClaim: tt.tokenIPAddr,
 					},
 				},
 			}
 
-			conf := &config.Config{
-				Oauth2: &config.OAuth2{
-					Validate: &config.OAuth2Validate{
-						IpAddr: tt.validateIpAddr,
+			conf := config.Config{
+				OAuth2: config.OAuth2{
+					Validate: config.OAuth2Validate{
+						IPAddr: tt.validateIPAddr,
 					},
 				},
 			}
 
-			session := &state.State{
-				Ipaddr: tt.requiredIpAddr,
+			session := state.State{
+				Ipaddr: tt.requiredIPAddr,
 			}
 
-			err := NewProvider(conf).CheckIpAddress(session, token)
+			err := generic.NewProvider(conf).CheckIPAddress(session, token)
 			if tt.err == "" {
 				assert.NoError(t, err)
-			} else {
-				assert.Equal(t, err.Error(), tt.err)
+			} else if assert.Error(t, err) {
+				assert.Equal(t, tt.err, err.Error())
 			}
 		})
 	}
diff --git a/internal/openvpn/client_connection_test.go b/internal/openvpn/client_connection_test.go
deleted file mode 100644
index d67fc97..0000000
--- a/internal/openvpn/client_connection_test.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package openvpn
-
-import (
-	"strings"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-)
-
-func TestNewClientConnection(t *testing.T) {
-	for _, tt := range []struct {
-		name             string
-		lines            []string
-		clientConnection *ClientConnection
-		err              string
-	}{
-		{"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": ""}}, ""},
-		{"client CR_RESPONSE", []string{">CLIENT:CR_RESPONSE,1,2,YmFzZTY0", ">CLIENT:ENV,name1=val1", ">CLIENT:ENV,name2=", ">CLIENT:ENV,END"}, &ClientConnection{Cid: 1, Kid: 2, Reason: "CR_RESPONSE", Env: map[string]string{"name1": "val1", "name2": ""}}, ""},
-		{"client invalid reason", []string{">CLIENT:unknown", ">CLIENT:ENV,name1=val1", ">CLIENT:ENV,name2", ">CLIENT:ENV,END"}, nil, "unable to parse client reason"},
-		{"client invalid reason", []string{">CLIENT:CONNECT", ">CLIENT:ENV,name1=val1", ">CLIENT:ENV,name2", ">CLIENT:ENV,END"}, nil, "unable to parse line >CLIENT:CONNECT"},
-	} {
-		t.Run(tt.name, func(t *testing.T) {
-			message := strings.Join(tt.lines, "\n")
-
-			clientConnection, err := NewClientConnection(message)
-			if tt.err == "" {
-				assert.NoError(t, err)
-				assert.Equal(t, clientConnection, tt.clientConnection)
-			} else {
-				assert.Error(t, err)
-				assert.Equal(t, err.Error(), tt.err)
-			}
-		})
-	}
-}
diff --git a/internal/openvpn/connection/client_test.go b/internal/openvpn/connection/client_test.go
new file mode 100644
index 0000000..3f7ca20
--- /dev/null
+++ b/internal/openvpn/connection/client_test.go
@@ -0,0 +1,112 @@
+package connection_test
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn/connection"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNewClientConnection(t *testing.T) {
+	t.Parallel()
+
+	for _, tt := range []struct {
+		name             string
+		lines            []string
+		clientConnection connection.Client
+		err              string
+	}{
+		{
+			"client CONNECT",
+			[]string{">CLIENT:CONNECT,0,1", ">CLIENT:ENV,name1=val1", ">CLIENT:ENV,name2=", ">CLIENT:ENV,END"},
+			connection.Client{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"},
+			connection.Client{Cid: 1, Kid: 2, Reason: "CONNECT", Env: map[string]string{
+				"name1": "val1", "name2": "",
+			}},
+			"unable to parse cid: 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"},
+			connection.Client{Cid: 1, Kid: 2, Reason: "CONNECT", Env: map[string]string{
+				"name1": "val1", "name2": "",
+			}},
+			"unable to parse kid: strconv.ParseUint: parsing \"k\": invalid syntax",
+		},
+		{
+			"client REAUTH",
+			[]string{">CLIENT:REAUTH,1,2", ">CLIENT:ENV,name1=val1", ">CLIENT:ENV,name2=", ">CLIENT:ENV,END"},
+			connection.Client{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"},
+			connection.Client{
+				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"},
+			connection.Client{
+				Cid: 1, Kid: 0, Reason: "DISCONNECT", Env: map[string]string{
+					"name1": "val1", "name2": "",
+				},
+			},
+			"",
+		},
+		{
+			"client CR_RESPONSE",
+			[]string{">CLIENT:CR_RESPONSE,1,2,YmFzZTY0", ">CLIENT:ENV,name1=val1", ">CLIENT:ENV,END"},
+			connection.Client{
+				Cid: 1, Kid: 2, Reason: "CR_RESPONSE", Env: map[string]string{
+					"name1": "val1",
+				},
+			},
+			"",
+		},
+		{
+			"client invalid reason",
+			[]string{">CLIENT:unknown", ">CLIENT:ENV,name1=val1", ">CLIENT:ENV,name2", ">CLIENT:ENV,END"},
+			connection.Client{},
+			"unable to parse client reason from message: >CLIENT:unknown\n>CLIENT:ENV,name1=val1\n>CLIENT:ENV,name2\n>CLIENT:ENV,END",
+		},
+		{
+			"client invalid reason",
+			[]string{">CLIENT:CONNECT", ">CLIENT:ENV,name1=val1", ">CLIENT:ENV,name2", ">CLIENT:ENV,END"},
+			connection.Client{},
+			"unable to parse line '>CLIENT:CONNECT': message invalid",
+		},
+	} {
+		tt := tt
+
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			message := strings.Join(tt.lines, "\n")
+
+			clientConnection, err := connection.NewClient(message)
+			if tt.err == "" {
+				assert.NoError(t, err)
+				assert.Equal(t, tt.clientConnection, clientConnection)
+			} else {
+				assert.Error(t, err)
+				assert.Equal(t, tt.err, err.Error())
+			}
+		})
+	}
+}
diff --git a/internal/openvpn/client_test.go b/internal/openvpn/main_test.go
similarity index 52%
rename from internal/openvpn/client_test.go
rename to internal/openvpn/main_test.go
index 8f19163..d8a8827 100644
--- a/internal/openvpn/client_test.go
+++ b/internal/openvpn/main_test.go
@@ -1,4 +1,4 @@
-package openvpn
+package openvpn_test
 
 import (
 	"bufio"
@@ -14,51 +14,52 @@ import (
 	"testing"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
 	"github.com/stretchr/testify/assert"
 )
 
 func TestClientInvalidServer(t *testing.T) {
+	t.Parallel()
+
 	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
-	conf := &config.Config{
-		Http: &config.Http{
-			BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
+	conf := config.Config{
+		HTTP: config.HTTP{
+			BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
 			Secret:  "0123456789101112",
 		},
-		OpenVpn: &config.OpenVpn{
+		OpenVpn: config.OpenVpn{
 			Addr:   &url.URL{Scheme: "tcp", Host: "0.0.0.0:1"},
-			Bypass: &config.OpenVpnBypass{CommonNames: make([]string, 0)},
+			Bypass: config.OpenVpnBypass{CommonNames: make([]string, 0)},
 		},
 	}
-	client := NewClient(logger, conf)
+	client := openvpn.NewClient(logger, conf)
 	err := client.Connect()
 	assert.Error(t, err)
 	assert.Equal(t, "unable to connect to openvpn management interface tcp://0.0.0.0:1: dial tcp 0.0.0.0:1: connect: connection refused", err.Error())
 }
 
 func TestClientFull(t *testing.T) {
+	t.Parallel()
+
 	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()
 
 	confs := []struct {
 		name   string
-		conf   *config.Config
+		conf   config.Config
 		client string
 		expect string
 		err    error
 	}{
 		{
 			"without password",
-			&config.Config{
-				Http: &config.Http{
-					BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
+			config.Config{
+				HTTP: config.HTTP{
+					BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
 					Secret:  "0123456789101112",
 				},
-				OpenVpn: &config.OpenVpn{
-					Addr:   &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
-					Bypass: &config.OpenVpnBypass{CommonNames: make([]string, 0)},
+				OpenVpn: config.OpenVpn{
+					Bypass: config.OpenVpnBypass{CommonNames: make([]string, 0)},
 				},
 			},
 			">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",
@@ -67,14 +68,13 @@ func TestClientFull(t *testing.T) {
 		},
 		{
 			"with password",
-			&config.Config{
-				Http: &config.Http{
-					BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
+			config.Config{
+				HTTP: config.HTTP{
+					BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
 					Secret:  "0123456789101112",
 				},
-				OpenVpn: &config.OpenVpn{
-					Addr:     &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
-					Bypass:   &config.OpenVpnBypass{CommonNames: make([]string, 0)},
+				OpenVpn: config.OpenVpn{
+					Bypass:   config.OpenVpnBypass{CommonNames: make([]string, 0)},
 					Password: "password",
 				},
 			},
@@ -84,31 +84,29 @@ func TestClientFull(t *testing.T) {
 		},
 		{
 			"with invalid state",
-			&config.Config{
-				Http: &config.Http{
-					BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
+			config.Config{
+				HTTP: config.HTTP{
+					BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
 					Secret:  "012345678910111",
 				},
-				OpenVpn: &config.OpenVpn{
-					Addr:     &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
-					Bypass:   &config.OpenVpnBypass{CommonNames: make([]string, 0)},
+				OpenVpn: config.OpenVpn{
+					Bypass:   config.OpenVpnBypass{CommonNames: make([]string, 0)},
 					Password: "password",
 				},
 			},
 			">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",
 			"",
-			errors.New("OpenVPN management error: error encoding state: crypto/aes: invalid key size 15"),
+			errors.New("OpenVPN management error: error encoding state: encrypt aes: crypto/aes: invalid key size 15"),
 		},
 		{
 			"client without IV_SSO",
-			&config.Config{
-				Http: &config.Http{
-					BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
+			config.Config{
+				HTTP: config.HTTP{
+					BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
 					Secret:  "0123456789101112",
 				},
-				OpenVpn: &config.OpenVpn{
-					Addr:     &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
-					Bypass:   &config.OpenVpnBypass{CommonNames: make([]string, 0)},
+				OpenVpn: config.OpenVpn{
+					Bypass:   config.OpenVpnBypass{CommonNames: make([]string, 0)},
 					Password: "password",
 				},
 			},
@@ -118,14 +116,13 @@ func TestClientFull(t *testing.T) {
 		},
 		{
 			"client bypass",
-			&config.Config{
-				Http: &config.Http{
-					BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
+			config.Config{
+				HTTP: config.HTTP{
+					BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
 					Secret:  "0123456789101112",
 				},
-				OpenVpn: &config.OpenVpn{
-					Addr:     &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
-					Bypass:   &config.OpenVpnBypass{CommonNames: []string{"bypass"}},
+				OpenVpn: config.OpenVpn{
+					Bypass:   config.OpenVpnBypass{CommonNames: []string{"bypass"}},
 					Password: "password",
 				},
 			},
@@ -135,14 +132,13 @@ func TestClientFull(t *testing.T) {
 		},
 		{
 			"client established",
-			&config.Config{
-				Http: &config.Http{
-					BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
+			config.Config{
+				HTTP: config.HTTP{
+					BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
 					Secret:  "0123456789101112",
 				},
-				OpenVpn: &config.OpenVpn{
-					Addr:     &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
-					Bypass:   &config.OpenVpnBypass{CommonNames: []string{"bypass"}},
+				OpenVpn: config.OpenVpn{
+					Bypass:   config.OpenVpnBypass{CommonNames: []string{"bypass"}},
 					Password: "password",
 				},
 			},
@@ -152,14 +148,13 @@ func TestClientFull(t *testing.T) {
 		},
 		{
 			"client disconnected",
-			&config.Config{
-				Http: &config.Http{
-					BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
+			config.Config{
+				HTTP: config.HTTP{
+					BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
 					Secret:  "0123456789101112",
 				},
-				OpenVpn: &config.OpenVpn{
-					Addr:     &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
-					Bypass:   &config.OpenVpnBypass{CommonNames: []string{"bypass"}},
+				OpenVpn: config.OpenVpn{
+					Bypass:   config.OpenVpnBypass{CommonNames: []string{"bypass"}},
 					Password: "password",
 				},
 			},
@@ -169,27 +164,35 @@ func TestClientFull(t *testing.T) {
 		},
 		{
 			"client invalid reason",
-			&config.Config{
-				Http: &config.Http{
-					BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
+			config.Config{
+				HTTP: config.HTTP{
+					BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
 					Secret:  "0123456789101112",
 				},
-				OpenVpn: &config.OpenVpn{
-					Addr:     &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
-					Bypass:   &config.OpenVpnBypass{CommonNames: []string{"bypass"}},
+				OpenVpn: config.OpenVpn{
+					Bypass:   config.OpenVpnBypass{CommonNames: []string{"bypass"}},
 					Password: "password",
 				},
 			},
 			">CLIENT:FOO,0\r\n>CLIENT:ENV,common_name=bypass\r\n>CLIENT:ENV,END\r\n",
 			"",
-			errors.New("OpenVPN management error: unable to parse client reason"),
+			//nolint:revive
+			errors.New("OpenVPN management error: unable to parse client reason from message: >CLIENT:FOO,0\n>CLIENT:ENV,common_name=bypass\n>CLIENT:ENV,END\n"),
 		},
 	}
 
 	for _, tt := range confs {
 		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
-			client := NewClient(logger, tt.conf)
+			t.Parallel()
+
+			l, err := net.Listen("tcp", "127.0.0.1:0")
+			assert.NoError(t, err)
+			defer l.Close()
+
+			tt.conf.OpenVpn.Addr = &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()}
+
+			client := openvpn.NewClient(logger, tt.conf)
 			wg := sync.WaitGroup{}
 			wg.Add(1)
 
@@ -198,7 +201,7 @@ func TestClientFull(t *testing.T) {
 				conn, err := l.Accept()
 				assert.NoError(t, err)
 
-				defer conn.Close() //nolint:errcheck
+				defer conn.Close()
 				defer client.Shutdown()
 
 				reader := bufio.NewReader(conn)
@@ -214,10 +217,11 @@ func TestClientFull(t *testing.T) {
 				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, "OpenVPN Version: OpenVPN Mock\r\nManagement Interface Version: 5\r\nEND\r\n")
 				sendLine(t, conn, tt.client)
 				if tt.err != nil {
 					_, _ = reader.ReadString('\n')
+
 					return
 				} else if tt.expect == "" {
 					return
@@ -234,7 +238,7 @@ func TestClientFull(t *testing.T) {
 					assert.Len(t, matches, 2)
 
 					sessionState := state.NewEncoded(matches[1])
-					err := sessionState.Decode(tt.conf.Http.Secret)
+					err := sessionState.Decode(tt.conf.HTTP.Secret)
 					assert.NoError(t, err)
 
 					assert.Equal(t, uint64(1), sessionState.Cid)
@@ -244,7 +248,7 @@ func TestClientFull(t *testing.T) {
 				}
 			}()
 
-			err := client.Connect()
+			err = client.Connect()
 			if tt.err != nil {
 				if assert.Error(t, err) {
 					assert.Equal(t, tt.err.Error(), err.Error())
@@ -261,30 +265,33 @@ func TestClientFull(t *testing.T) {
 }
 
 func TestClientInvalidPassword(t *testing.T) {
+	t.Parallel()
+
 	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: &url.URL{Scheme: "http", Host: "localhost"},
+	conf := config.Config{
+		HTTP: config.HTTP{
+			BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
 			Secret:  "0123456789101112",
 		},
-		OpenVpn: &config.OpenVpn{
+		OpenVpn: config.OpenVpn{
 			Addr:     &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
-			Bypass:   &config.OpenVpnBypass{CommonNames: make([]string, 0)},
+			Bypass:   config.OpenVpnBypass{CommonNames: make([]string, 0)},
 			Password: "invalid",
 		},
 	}
 
-	client := NewClient(logger, conf)
+	client := openvpn.NewClient(logger, conf)
 
 	go func() {
 		conn, err := l.Accept()
 		assert.NoError(t, err)
 
-		defer conn.Close() //nolint:errcheck
+		defer conn.Close()
 		reader := bufio.NewReader(conn)
 
 		sendLine(t, conn, "ENTER PASSWORD:")
@@ -295,19 +302,102 @@ func TestClientInvalidPassword(t *testing.T) {
 	}()
 
 	err = client.Connect()
+
 	if assert.Error(t, err) {
-		assert.Equal(t, "wrong openvpn management interface password", err.Error())
+		assert.Equal(t, "unable to connect to openvpn management interface: invalid password", err.Error())
 	}
+
 	client.Shutdown()
 }
 
-func sendLine(t testing.TB, conn net.Conn, msg string, a ...any) {
+func TestClientInvalidVersion(t *testing.T) {
+	t.Parallel()
+
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+
+	conf := config.Config{
+		HTTP: config.HTTP{
+			BaseURL: &url.URL{Scheme: "http", Host: "localhost"},
+			Secret:  "0123456789101112",
+		},
+		OpenVpn: config.OpenVpn{
+			Bypass: config.OpenVpnBypass{CommonNames: make([]string, 0)},
+		},
+	}
+
+	versions := []struct {
+		name    string
+		version string
+		err     string
+	}{
+		{
+			"invalid parts",
+			"OpenVPN Version: OpenVPN Mock\r\nEND\r\n",
+			"unexpected response from version command: OpenVPN Version: OpenVPN Mock\nEND\n",
+		},
+		{
+			"invalid version",
+			"OpenVPN Version: OpenVPN Mock\r\nManagement Interface Version:\r\nEND\r\n",
+			`unable to parse openvpn management interface version: strconv.Atoi: parsing ":": invalid syntax`,
+		},
+		{
+			"version to low",
+			"OpenVPN Version: OpenVPN Mock\r\nManagement Interface Version: 4\r\nEND\r\n",
+			`openvpn-auth-oauth2 requires OpenVPN management interface version 5 or higher`,
+		},
+	}
+
+	for _, tt := range versions {
+		tt, conf := tt, conf
+
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			l, err := net.Listen("tcp", "127.0.0.1:0")
+			assert.NoError(t, err)
+			defer l.Close()
+
+			conf.OpenVpn.Addr = &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()}
+
+			client := openvpn.NewClient(logger, conf)
+
+			go func() {
+				conn, err := l.Accept()
+				assert.NoError(t, err)
+
+				defer conn.Close()
+				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, tt.version)
+			}()
+
+			err = client.Connect()
+			if assert.Error(t, err) {
+				assert.Equal(t, tt.err, err.Error())
+			}
+			client.Shutdown()
+		})
+	}
+}
+
+func sendLine(tb testing.TB, conn net.Conn, msg string, a ...any) {
+	tb.Helper()
+
 	_, err := fmt.Fprintf(conn, msg, a...)
-	assert.NoError(t, err)
+	assert.NoError(tb, err)
 }
 
-func readLine(t testing.TB, reader *bufio.Reader) (msg string) {
+func readLine(tb testing.TB, reader *bufio.Reader) string {
+	tb.Helper()
+
 	line, err := reader.ReadString('\n')
-	assert.NoError(t, err)
+	assert.NoError(tb, err)
+
 	return strings.TrimSpace(line)
 }
diff --git a/internal/state/state_test.go b/internal/state/state_test.go
index 065e71a..165ca03 100644
--- a/internal/state/state_test.go
+++ b/internal/state/state_test.go
@@ -1,58 +1,70 @@
-package state
+package state_test
 
 import (
 	"testing"
 	"time"
 
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
 	"github.com/stretchr/testify/assert"
 )
 
 func TestState(t *testing.T) {
+	t.Parallel()
+
 	encryptionKey := "0123456789101112"
 
 	for i := 1; i < 50; i++ {
-		state := New(1, 2, "127.0.0.1", "test")
-		assert.NoError(t, state.Encode(encryptionKey))
+		token := state.New(1, 2, "127.0.0.1", "test")
+		assert.NoError(t, token.Encode(encryptionKey))
 
-		encryptedState := NewEncoded(state.Encoded)
-		assert.NoError(t, encryptedState.Decode(encryptionKey))
+		encodedToken := state.NewEncoded(token.Encoded())
+		assert.NoError(t, encodedToken.Decode(encryptionKey))
 
-		assert.Equal(t, state.Cid, encryptedState.Cid)
-		assert.Equal(t, state.Kid, encryptedState.Kid)
-		assert.Equal(t, state.Ipaddr, encryptedState.Ipaddr)
-		assert.Equal(t, state.CommonName, encryptedState.CommonName)
+		assert.Equal(t, token.Cid, encodedToken.Cid)
+		assert.Equal(t, token.Kid, encodedToken.Kid)
+		assert.Equal(t, token.Ipaddr, encodedToken.Ipaddr)
+		assert.Equal(t, token.CommonName, encodedToken.CommonName)
 	}
 }
+
 func TestStateInvalid_Key(t *testing.T) {
+	t.Parallel()
+
 	encryptionKey := "01234567891011"
 
-	state := New(1, 2, "127.0.0.1", "test")
-	assert.Error(t, state.Encode(encryptionKey), "crypto/aes: invalid key size 14")
+	token := state.New(1, 2, "127.0.0.1", "test")
+	assert.Error(t, token.Encode(encryptionKey), "crypto/aes: invalid key size 14")
 }
 
 func TestStateInvalid_Future(t *testing.T) {
+	t.Parallel()
+
 	encryptionKey := "0123456789101112"
 
-	state := New(1, 2, "127.0.0.1", "test")
-	state.Issued = time.Now().Add(time.Hour)
+	token := state.New(1, 2, "127.0.0.1", "test")
+	token.Issued = time.Now().Add(time.Hour)
 
-	assert.NoError(t, state.Encode(encryptionKey))
-	assert.Contains(t, state.Decode(encryptionKey).Error(), "state issued in future, issued at:")
+	assert.NoError(t, token.Encode(encryptionKey))
+	assert.Contains(t, token.Decode(encryptionKey).Error(), "invalid state: issued in future, issued at:")
 }
 
 func TestStateInvalid_TooOld(t *testing.T) {
+	t.Parallel()
+
 	encryptionKey := "0123456789101112"
 
-	state := New(1, 2, "127.0.0.1", "test")
-	state.Issued = time.Now().Add(-1 * time.Hour)
+	token := state.New(1, 2, "127.0.0.1", "test")
+	token.Issued = time.Now().Add(-1 * time.Hour)
 
-	assert.NoError(t, state.Encode(encryptionKey))
-	assert.Contains(t, state.Decode(encryptionKey).Error(), "state expired after 2 minutes, issued at:")
+	assert.NoError(t, token.Encode(encryptionKey))
+	assert.Contains(t, token.Decode(encryptionKey).Error(), "invalid state: expired after 2 minutes, issued at:")
 }
 
 func TestStateInvalid_Encoded(t *testing.T) {
+	t.Parallel()
+
 	encryptionKey := "0123456789101112"
 
-	state := NewEncoded("test")
-	assert.Error(t, state.Decode(encryptionKey))
+	token := state.NewEncoded("test")
+	assert.Error(t, token.Decode(encryptionKey))
 }
diff --git a/internal/utils/slices_test.go b/internal/utils/slices_test.go
index 98874bc..0267954 100644
--- a/internal/utils/slices_test.go
+++ b/internal/utils/slices_test.go
@@ -1,13 +1,34 @@
-package utils
+package utils_test
 
 import (
 	"testing"
 
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
 	"github.com/stretchr/testify/assert"
 )
 
 func TestCastToSlice(t *testing.T) {
-	stringSlice := CastToSlice[string]([]any{"string1", "string2", "string3"})
+	t.Parallel()
 
+	stringSlice, err := utils.CastToSlice[string]([]any{"string1", "string2", "string3"})
+	assert.NoError(t, err)
 	assert.Equal(t, stringSlice, []string{"string1", "string2", "string3"})
 }
+
+func TestCastToSliceInvalidSlice(t *testing.T) {
+	t.Parallel()
+
+	_, err := utils.CastToSlice[string]("string")
+	if assert.Error(t, err) {
+		assert.Equal(t, "unable to cast input to []any", err.Error())
+	}
+}
+
+func TestCastToSliceInvalidElement(t *testing.T) {
+	t.Parallel()
+
+	_, err := utils.CastToSlice[string]([]any{"string1", "string2", 0})
+	if assert.Error(t, err) {
+		assert.Equal(t, "unable to cast element", err.Error())
+	}
+}
diff --git a/internal/utils/strings_test.go b/internal/utils/strings_test.go
index ee1ebc0..d6978c9 100644
--- a/internal/utils/strings_test.go
+++ b/internal/utils/strings_test.go
@@ -1,11 +1,13 @@
-package utils
+package utils_test
 
 import (
 	"testing"
 
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
 	"github.com/stretchr/testify/assert"
 )
 
 func TestStringConcat(t *testing.T) {
-	assert.Equal(t, "aabbcc", StringConcat("aa", "b", "b", "cc"))
+	t.Parallel()
+	assert.Equal(t, "aabbcc", utils.StringConcat("aa", "b", "b", "cc"))
 }
diff --git a/internal/utils/url_test.go b/internal/utils/url_test.go
index 7109192..9952087 100644
--- a/internal/utils/url_test.go
+++ b/internal/utils/url_test.go
@@ -1,13 +1,17 @@
-package utils
+package utils_test
 
 import (
 	"net/url"
 	"testing"
 
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
 	"github.com/stretchr/testify/assert"
 )
 
+//nolint:exhaustruct
 func TestIsUrlEmpty(t *testing.T) {
+	t.Parallel()
+
 	for _, tt := range []struct {
 		name   string
 		url    *url.URL
@@ -29,8 +33,11 @@ func TestIsUrlEmpty(t *testing.T) {
 			false,
 		},
 	} {
+		tt := tt
+
 		t.Run(tt.name, func(t *testing.T) {
-			assert.Equal(t, tt.expect, IsUrlEmpty(tt.url))
+			t.Parallel()
+			assert.Equal(t, tt.expect, utils.IsURLEmpty(tt.url))
 		})
 	}
 }