Repository: jkroepke/openvpn-auth-oauth2
Pull request #40 (2023-10-04 11:58:37)

feat: Prepare OpenVPN server plugin
**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**:

**Particularly user-facing changes**:

**PR Readiness Checklist**:

Complete these before marking the PR as `ready to review`:

- [ ] The PR title has a summary of the changes
- [ ] The PR body has a summary to reflect any significant (and particularly user-facing) changes introduced by this PR


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/40?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: `70 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`52ccc49`)](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/commit/52ccc493f77176ed0413d4d152e0fb63cd102580?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) 71.16% compared to head [(`d9ca951`)](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/40?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.50%.

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


```diff
@@            Coverage Diff             @@
##             main      #40      +/-   ##
==========================================
+ Coverage   71.16%   71.50%   +0.34%     
==========================================
  Files          17       19       +2     
  Lines        1134     1274     +140     
==========================================
+ Hits          807      911     +104     
- Misses        274      317      +43     
+ Partials       53       46       -7     
```


| [Files](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/40?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 Δ | |
|---|---|---|
| [cmd/root.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/40?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=) | `70.76% <100.00%> (+0.88%)` | :arrow_up: |
| [internal/config/config.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/40?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==) | `96.09% <100.00%> (+6.90%)` | :arrow_up: |
| [internal/oauth2/provider.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/40?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) | `78.64% <100.00%> (ø)` | |
| [internal/openvpn/client.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/40?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=) | `88.15% <100.00%> (-3.51%)` | :arrow_down: |
| [internal/state/state.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/40?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-aW50ZXJuYWwvc3RhdGUvc3RhdGUuZ28=) | `84.61% <100.00%> (-0.39%)` | :arrow_down: |
| [internal/oauth2/handler.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/40?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=) | `54.19% <76.19%> (+4.78%)` | :arrow_up: |
| [internal/openvpn/callbacks.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/40?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-aW50ZXJuYWwvb3BlbnZwbi9jYWxsYmFja3MuZ28=) | `0.00% <0.00%> (ø)` | |
| [internal/config/load.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/40?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-aW50ZXJuYWwvY29uZmlnL2xvYWQuZ28=) | `0.00% <0.00%> (ø)` | |

... and [1 file with indirect coverage changes](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/40/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke)


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/40?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/cmd/root.go b/cmd/root.go
index a1e2918..db4ec1e 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -10,7 +10,6 @@ import (
 	"os"
 	"os/signal"
 	"runtime"
-	"strings"
 	"syscall"
 	"time"
 
@@ -18,13 +17,6 @@ import (
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
-	"github.com/knadh/koanf/parsers/yaml"
-	"github.com/knadh/koanf/providers/env"
-	"github.com/knadh/koanf/providers/file"
-	"github.com/knadh/koanf/providers/posflag"
-	"github.com/knadh/koanf/v2"
-	"github.com/mitchellh/mapstructure"
-	flag "github.com/spf13/pflag"
 )
 
 func Execute(args []string, logWriter io.Writer, version, commit, date string) int {
@@ -47,7 +39,9 @@ func Execute(args []string, logWriter io.Writer, version, commit, date string) i
 		return 0
 	}
 
-	conf, err := loadConfig(flagSet)
+	configFile, _ := flagSet.GetString("config")
+
+	conf, err := config.Load(config.ManagementClient, configFile, flagSet)
 	if err != nil {
 		logger.Error(fmt.Errorf("error loading config: %w", err).Error())
 
@@ -183,63 +177,3 @@ func configureLogger(conf config.Config, writer io.Writer) (*slog.Logger, error)
 		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/go.mod b/go.mod
index 1f25c9d..0e2c45c 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
 	github.com/knadh/koanf/providers/env v0.1.0
 	github.com/knadh/koanf/providers/file v0.1.0
 	github.com/knadh/koanf/providers/posflag v0.1.0
+	github.com/knadh/koanf/providers/structs v0.1.0
 	github.com/knadh/koanf/v2 v2.0.1
 	github.com/mitchellh/mapstructure v1.5.0
 	github.com/spf13/pflag v1.0.5
@@ -18,6 +19,7 @@ require (
 
 require (
 	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/fatih/structs v1.1.0 // indirect
 	github.com/fsnotify/fsnotify v1.6.0 // indirect
 	github.com/go-logr/logr v1.2.4 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
diff --git a/go.sum b/go.sum
index 3696e40..03f2ef5 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,8 @@
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
+github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
 github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -37,6 +39,8 @@ github.com/knadh/koanf/providers/file v0.1.0 h1:fs6U7nrV58d3CFAFh8VTde8TM262ObYf
 github.com/knadh/koanf/providers/file v0.1.0/go.mod h1:rjJ/nHQl64iYCtAW2QQnF0eSmDEX/YZ/eNFj5yR6BvA=
 github.com/knadh/koanf/providers/posflag v0.1.0 h1:mKJlLrKPcAP7Ootf4pBZWJ6J+4wHYujwipe7Ie3qW6U=
 github.com/knadh/koanf/providers/posflag v0.1.0/go.mod h1:SYg03v/t8ISBNrMBRMlojH8OsKowbkXV7giIbBVgbz0=
+github.com/knadh/koanf/providers/structs v0.1.0 h1:wJRteCNn1qvLtE5h8KQBvLJovidSdntfdyIbbCzEyE0=
+github.com/knadh/koanf/providers/structs v0.1.0/go.mod h1:sw2YZ3txUcqA3Z27gPlmmBzWn1h8Nt9O6EP/91MkcWE=
 github.com/knadh/koanf/v2 v2.0.1 h1:1dYGITt1I23x8cfx8ZnldtezdyaZtfAuRtIFOiRzK7g=
 github.com/knadh/koanf/v2 v2.0.1/go.mod h1:ZeiIlIDXTE7w1lMT6UVcNiRAS2/rCeLn/GdLNvY1Dus=
 github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
diff --git a/internal/config/config.go b/internal/config/config.go
index acf22d2..4b22b45 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -1,4 +1,4 @@
-//nolint:lll,goerr113
+//nolint:goerr113
 package config
 
 import (
@@ -12,6 +12,11 @@ import (
 	flag "github.com/spf13/pflag"
 )
 
+const (
+	Plugin = iota
+	ManagementClient
+)
+
 // FlagSet configure the command line parser using [flag] library.
 func FlagSet() *flag.FlagSet {
 	flagSet := flag.NewFlagSet("openvpn-auth-oauth2", flag.ContinueOnError)
@@ -21,44 +26,167 @@ func FlagSet() *flag.FlagSet {
 		os.Exit(0)
 	}
 
-	//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")
+	flagSet.String(
+		"config",
+		"",
+		"path to one .yaml config files. (env: CONFIG_CONFIG)",
+	)
+	flagSet.String(
+		"log.format",
+		Defaults.Log.Format,
+		"log format. json or console (env: CONFIG_LOG_FORMAT)",
+	)
+	flagSet.String(
+		"log.level",
+		Defaults.Log.Level,
+		"log level. (env: CONFIG_LOG_LEVEL)",
+	)
+	flagSet.String(
+		"http.listen",
+		Defaults.HTTP.Listen,
+		"listen addr for client listener. (env: CONFIG_HTTP_LISTEN)",
+	)
+	flagSet.Bool(
+		"http.tls",
+		Defaults.HTTP.TLS,
+		"enable TLS listener. (env: CONFIG_HTTP_TLS)",
+	)
+	flagSet.String(
+		"http.baseurl",
+		Defaults.HTTP.BaseURL.String(),
+		"listen addr for client listener. (env: CONFIG_HTTP_BASEURL)",
+	)
+	flagSet.String(
+		"http.secret",
+		Defaults.HTTP.Secret,
+		"Cookie secret. (env: CONFIG_HTTP_SECRET)",
+	)
+	flagSet.String(
+		"http.key",
+		Defaults.HTTP.KeyFile,
+		"Path to tls server key. (env: CONFIG_HTTP_KEY)",
+	)
+	flagSet.String(
+		"http.cert",
+		Defaults.HTTP.CertFile,
+		"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",
+		Defaults.HTTP.Check.IPAddr,
+		"Check if client IP in http and VPN is equal. (env: CONFIG_HTTP_CHECK_IPADDR)",
+	)
+	flagSet.Bool(
+		"http.enable-proxy-headers",
+		Defaults.HTTP.EnableProxyHeaders,
+		"Use X-Forward-For http header for client ips. (env: CONFIG_HTTP_ENABLE_PROXY_HEADERS)",
+	)
+	flagSet.String(
+		"openvpn.addr",
+		Defaults.OpenVpn.Addr.String(),
+		"openvpn management interface addr. Must start with unix:// or tcp:// (env: CONFIG_OPENVPN_ADDR)",
+	)
+	flagSet.String(
+		"openvpn.password",
+		Defaults.OpenVpn.Password,
+		"openvpn management interface password. (env: CONFIG_OPENVPN_PASSWORD)",
+	)
+	flagSet.Bool(
+		"openvpn.auth-token-user",
+		Defaults.OpenVpn.AuthTokenUser,
+		"Define auth-token-user for all sessions. (env: CONFIG_OPENVPN_AUTH_TOKEN_USER)",
+	)
+	flagSet.StringSlice(
+		"openvpn.bypass.cn",
+		Defaults.OpenVpn.Bypass.CommonNames,
+		"bypass oauth authentication for CNs. (env: CONFIG_OAUTH2_BYPASS_CN)",
+	)
+	flagSet.String(
+		"oauth2.issuer",
+		Defaults.OAuth2.Issuer.String(),
+		"oauth2 issuer. (env: CONFIG_OAUTH2_ISSUER)",
+	)
+	flagSet.String(
+		"oauth2.provider",
+		Defaults.OAuth2.Provider,
+		"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",
+		Defaults.OAuth2.Endpoints.Discovery.String(),
+		"custom oauth2 discovery url. (env: CONFIG_OAUTH2_ENDPOINT_DISCOVERY)",
+	)
+	flagSet.String(
+		"oauth2.endpoint.auth",
+		Defaults.OAuth2.Endpoints.Auth.String(),
+		"custom oauth2 auth endpoint. (env: CONFIG_OAUTH2_ENDPOINT_AUTH)",
+	)
+	flagSet.String(
+		"oauth2.endpoint.token",
+		Defaults.OAuth2.Endpoints.Token.String(),
+		"custom oauth2 token endpoint. (env: CONFIG_OAUTH2_ENDPOINT_TOKEN)",
+	)
+	flagSet.String(
+		"oauth2.client.id",
+		Defaults.OAuth2.Client.ID,
+		"oauth2 client id. (env: CONFIG_OAUTH2_CLIENT_ID)",
+	)
+	flagSet.String(
+		"oauth2.client.secret",
+		Defaults.OAuth2.Client.Secret,
+		"oauth2 client secret. (env: CONFIG_OAUTH2_CLIENT_SECRET)",
+	)
+	flagSet.StringSlice(
+		"oauth2.validate.groups",
+		Defaults.OAuth2.Validate.Groups,
+		"oauth2 required user groups. (env: CONFIG_OAUTH2_VALIDATE_GROUPS)",
+	)
+	flagSet.StringSlice(
+		"oauth2.validate.roles",
+		Defaults.OAuth2.Validate.Roles,
+		"oauth2 required user roles. (env: CONFIG_OAUTH2_VALIDATE_ROLES)",
+	)
+	flagSet.Bool(
+		"oauth2.validate.ipaddr",
+		Defaults.OAuth2.Validate.IPAddr,
+		"validate client ipaddr between VPN and oidc token. (env: CONFIG_OAUTH2_VALIDATE_IPADDR)",
+	)
+	flagSet.Bool(
+		"oauth2.validate.issuer",
+		Defaults.OAuth2.Validate.Issuer,
+		"validate issuer from oidc discovery. (env: CONFIG_OAUTH2_VALIDATE_ISSUER)",
+	)
+	flagSet.String(
+		"oauth2.validate.common_name",
+		Defaults.OAuth2.Validate.CommonName,
+		"validate common_name from OpenVPN with IDToken claim. (env: CONFIG_OAUTH2_VALIDATE_COMMON_NAME)",
+	)
+	flagSet.StringSlice(
+		"oauth2.scopes",
+		Defaults.OAuth2.Scopes,
+		"oauth2 token scopes. (env: CONFIG_OAUTH2_SCOPES)",
+	)
+	flagSet.Bool(
+		"version",
+		false,
+		"shows versions",
+	)
 
 	return flagSet
 }
 
 // Validate validates the config.
-func Validate(conf Config) error {
+func Validate(mode int, conf Config) error { //nolint:cyclop
 	for key, value := range map[string]string{
 		"http.secret":      conf.HTTP.Secret,
 		"oauth2.client.id": conf.OAuth2.Client.ID,
@@ -81,10 +209,6 @@ func Validate(conf Config) error {
 		return errors.New("http.secret requires a length of 16, 24 or 32")
 	}
 
-	if !slices.Contains([]string{"tcp", "unix"}, conf.OpenVpn.Addr.Scheme) {
-		return errors.New("openvpn.addr: invalid URL. only tcp://addr or unix://addr scheme supported")
-	}
-
 	for key, uri := range map[string]*url.URL{
 		"http.baseurl":              conf.HTTP.BaseURL,
 		"oauth2.issuer":             conf.OAuth2.Issuer,
@@ -101,5 +225,19 @@ func Validate(conf Config) error {
 		}
 	}
 
+	if mode == ManagementClient {
+		for key, value := range map[string]*url.URL{
+			"openvpn.addr": conf.OpenVpn.Addr,
+		} {
+			if utils.IsURLEmpty(value) {
+				return errors.New(utils.StringConcat(key, " is required"))
+			}
+		}
+
+		if !slices.Contains([]string{"tcp", "unix"}, conf.OpenVpn.Addr.Scheme) {
+			return errors.New("openvpn.addr: invalid URL. only tcp://addr or unix://addr scheme supported")
+		}
+	}
+
 	return nil
 }
diff --git a/internal/config/defaults.go b/internal/config/defaults.go
new file mode 100644
index 0000000..2bf9399
--- /dev/null
+++ b/internal/config/defaults.go
@@ -0,0 +1,50 @@
+package config
+
+import "net/url"
+
+//nolint:gochecknoglobals
+var Defaults = Config{
+	Log: Log{
+		Format: "console",
+		Level:  "info",
+	},
+	HTTP: HTTP{
+		BaseURL: &url.URL{
+			Scheme: "http",
+			Host:   "localhost:9000",
+		},
+		Listen: ":9000",
+		TLS:    false,
+		Check: HTTPCheck{
+			IPAddr: false,
+		},
+	},
+	OpenVpn: OpenVpn{
+		Addr: &url.URL{
+			Scheme:   "unix",
+			Path:     "/run/openvpn/server.sock",
+			OmitHost: true,
+		},
+		AuthTokenUser: true,
+		Bypass: OpenVpnBypass{
+			CommonNames: make([]string, 0),
+		},
+	},
+	OAuth2: OAuth2{
+		Provider: "generic",
+		Issuer:   &url.URL{Scheme: "", Host: ""},
+		Endpoints: OAuth2Endpoints{
+			Auth:      &url.URL{Scheme: "", Host: ""},
+			Discovery: &url.URL{Scheme: "", Host: ""},
+			Token:     &url.URL{Scheme: "", Host: ""},
+		},
+		Client: OAuth2Client{},
+		Validate: OAuth2Validate{
+			Groups: make([]string, 0),
+			Roles:  make([]string, 0),
+			IPAddr: false,
+			Issuer: true,
+		},
+		Scopes: []string{"openid", "profile"},
+	},
+}
diff --git a/internal/config/load.go b/internal/config/load.go
new file mode 100644
index 0000000..a327157
--- /dev/null
+++ b/internal/config/load.go
@@ -0,0 +1,80 @@
+package config
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/knadh/koanf/parsers/yaml"
+	"github.com/knadh/koanf/providers/env"
+	"github.com/knadh/koanf/providers/file"
+	"github.com/knadh/koanf/providers/posflag"
+	"github.com/knadh/koanf/providers/structs"
+	"github.com/knadh/koanf/v2"
+	"github.com/mitchellh/mapstructure"
+	flag "github.com/spf13/pflag"
+)
+
+func Load(mode int, configFile string, flagSet *flag.FlagSet) (Config, error) {
+	var err error
+
+	k := koanf.New(".")
+
+	if err := k.Load(structs.Provider(Defaults, "koanf"), nil); err != nil {
+		return Config{}, fmt.Errorf("loading defaults: %w", err)
+	}
+
+	if configFile != "" {
+		if err := k.Load(file.Provider(configFile), yaml.Parser()); err != nil {
+			return Config{}, fmt.Errorf("file provider: %w", err)
+		}
+	}
+
+	if flagSet != nil {
+		if err = k.Load(posflag.Provider(flagSet, ".", k), nil); err != nil {
+			return Config{}, fmt.Errorf("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{}, fmt.Errorf("env provider: %w", err)
+	}
+
+	var conf Config
+	unmarshalConf := koanf.UnmarshalConf{
+		DecoderConfig: &mapstructure.DecoderConfig{
+			DecodeHook: mapstructure.ComposeDecodeHookFunc(
+				mapstructure.StringToTimeDurationHookFunc(),
+				mapstructure.TextUnmarshallerHookFunc(),
+				StringToURLHookFunc(),
+				StringToTemplateHookFunc(),
+			),
+			Metadata:         nil,
+			Result:           &conf,
+			WeaklyTypedInput: true,
+		},
+	}
+
+	if err = k.UnmarshalWithConf("", &conf, unmarshalConf); err != nil {
+		return Config{}, fmt.Errorf("error unmarschal config: %w", err)
+	}
+
+	if err = Validate(mode, conf); err != nil {
+		return Config{}, fmt.Errorf("validation error: %w", err)
+	}
+
+	return conf, nil
+}
diff --git a/internal/oauth2/handler.go b/internal/oauth2/handler.go
index 7eb7c8b..4108f1f 100644
--- a/internal/oauth2/handler.go
+++ b/internal/oauth2/handler.go
@@ -10,7 +10,6 @@ import (
 	"time"
 
 	"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"
@@ -18,18 +17,24 @@ import (
 	"github.com/zitadel/oidc/v2/pkg/oidc"
 )
 
-func Handler(logger *slog.Logger, conf config.Config, provider Provider, openvpnClient *openvpn.Client) *http.ServeMux {
+type openVPN interface {
+	AcceptClient(logger *slog.Logger, client state.ClientIdentifier)
+	AcceptClientWithToken(logger *slog.Logger, client state.ClientIdentifier, username string)
+	DenyClient(logger *slog.Logger, client state.ClientIdentifier, reason string)
+}
+
+func Handler(logger *slog.Logger, conf config.Config, provider Provider, openvpnCallback openVPN) *http.ServeMux {
 	basePath := strings.TrimSuffix(conf.HTTP.BaseURL.Path, "/")
 
 	mux := http.NewServeMux()
 	mux.Handle("/", http.NotFoundHandler())
-	mux.Handle(utils.StringConcat(basePath, "/oauth2/start"), oauth2Start(logger, provider, conf, openvpnClient))
-	mux.Handle(utils.StringConcat(basePath, "/oauth2/callback"), oauth2Callback(logger, provider, conf, openvpnClient))
+	mux.Handle(utils.StringConcat(basePath, "/oauth2/start"), oauth2Start(logger, provider, conf, openvpnCallback))
+	mux.Handle(utils.StringConcat(basePath, "/oauth2/callback"), oauth2Callback(logger, provider, conf, openvpnCallback))
 
 	return mux
 }
 
-func oauth2Start(logger *slog.Logger, provider Provider, conf config.Config, openvpnClient *openvpn.Client) http.Handler {
+func oauth2Start(logger *slog.Logger, provider Provider, conf config.Config, openvpn openVPN) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		sessionState := r.URL.Query().Get("state")
 		if sessionState == "" {
@@ -49,13 +54,14 @@ func oauth2Start(logger *slog.Logger, provider Provider, conf config.Config, ope
 
 		logger = logger.With(
 			slog.String("common_name", session.CommonName),
-			slog.Uint64("cid", session.Cid),
-			slog.Uint64("kid", session.Kid),
+			slog.Uint64("cid", session.Client.Cid),
+			slog.Uint64("kid", session.Client.Kid),
 		)
 
 		if conf.HTTP.Check.IPAddr {
-			ok, httpStatusCode := checkClientIPAddr(r, logger, session, openvpnClient, conf)
+			ok, httpStatusCode, denyReason := checkClientIPAddr(r, logger, session, conf)
 			if !ok {
+				openvpn.DenyClient(logger, session.Client, denyReason)
 				w.WriteHeader(httpStatusCode)
 
 				return
@@ -70,19 +76,12 @@ func oauth2Start(logger *slog.Logger, provider Provider, conf config.Config, ope
 	})
 }
 
-func checkClientIPAddr(
-	r *http.Request, logger *slog.Logger, session state.State, openvpnClient *openvpn.Client, conf config.Config,
-) (bool, int) {
+func checkClientIPAddr(r *http.Request, logger *slog.Logger, session state.State, conf config.Config) (bool, int, string) {
 	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
+		return false, http.StatusInternalServerError, "client rejected"
 	}
 
 	if strings.HasPrefix(r.RemoteAddr, "[") {
@@ -99,22 +98,17 @@ func checkClientIPAddr(
 		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 false, http.StatusForbidden, reason
 	}
 
-	return true, 0
+	return true, 0, ""
 }
 
 func oauth2Callback(
-	logger *slog.Logger, provider Provider, conf config.Config, openvpnClient *openvpn.Client,
+	logger *slog.Logger, provider Provider, conf config.Config, openvpn openVPN,
 ) http.Handler {
 	return rp.CodeExchangeHandler(func(
-		w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], sessionState string,
+		w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], encryptedSession string,
 		rp rp.RelyingParty,
 	) {
 		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -125,10 +119,10 @@ func oauth2Callback(
 			slog.String("preferred_username", tokens.IDTokenClaims.PreferredUsername),
 		)
 
-		session := state.NewEncoded(sessionState)
+		session := state.NewEncoded(encryptedSession)
 		if err := session.Decode(conf.HTTP.Secret); err != nil {
 			logger.Warn(err.Error())
-			logger.Debug(sessionState)
+			logger.Debug(encryptedSession)
 			writeError(w, logger, conf, http.StatusInternalServerError, "invalidSession", err.Error())
 
 			return
@@ -136,19 +130,14 @@ func oauth2Callback(
 
 		logger = logger.With(
 			slog.String("common_name", session.CommonName),
-			slog.Uint64("cid", session.Cid),
-			slog.Uint64("kid", session.Kid),
+			slog.Uint64("cid", session.Client.Cid),
+			slog.Uint64("kid", session.Client.Kid),
 		)
 
 		user, err := provider.OIDC.GetUser(ctx, tokens)
 		if err != nil {
 			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())
-			}
-
+			openvpn.DenyClient(logger, session.Client, "unable to fetch user data")
 			writeError(w, logger, conf, http.StatusInternalServerError, "fetchUser", err.Error())
 
 			return
@@ -158,11 +147,7 @@ func oauth2Callback(
 		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())
-			}
+			openvpn.DenyClient(logger, session.Client, "client rejected")
 
 			writeError(w, logger, conf, http.StatusInternalServerError, "tokenValidation", reason)
 
@@ -173,13 +158,9 @@ func oauth2Callback(
 
 		if conf.OpenVpn.AuthTokenUser {
 			username := getAuthTokenUsername(session, user)
-			_, err = openvpnClient.SendCommandf("client-auth %d %d\npush \"auth-token-user %s\"\nEND", session.Cid, session.Kid, username)
+			openvpn.AcceptClientWithToken(logger, session.Client, username)
 		} else {
-			_, err = openvpnClient.SendCommandf("client-auth-nt %d %d", session.Cid, session.Kid)
-		}
-
-		if err != nil {
-			logger.Warn(err.Error())
+			openvpn.AcceptClient(logger, session.Client)
 		}
 
 		writeSuccess(w, conf, logger)
diff --git a/internal/oauth2/provider.go b/internal/oauth2/provider.go
index 34e0fb0..e4d82c3 100644
--- a/internal/oauth2/provider.go
+++ b/internal/oauth2/provider.go
@@ -68,7 +68,7 @@ func NewProvider(logger *slog.Logger, conf config.Config) (Provider, error) {
 		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(),
+				provider.GetName(), " for issuer ", conf.OAuth2.Issuer.String(),
 				"with custom discovery url", conf.OAuth2.Endpoints.Discovery.String(),
 			))
 
@@ -76,7 +76,7 @@ func NewProvider(logger *slog.Logger, conf config.Config) (Provider, error) {
 		} else {
 			logger.Info(utils.StringConcat(
 				"discover oidc auto configuration with provider ",
-				provider.GetName(), "for issuer ", conf.OAuth2.Issuer.String(),
+				provider.GetName(), " for issuer ", conf.OAuth2.Issuer.String(),
 			))
 		}
 
diff --git a/internal/openvpn/callbacks.go b/internal/openvpn/callbacks.go
new file mode 100644
index 0000000..e579cc9
--- /dev/null
+++ b/internal/openvpn/callbacks.go
@@ -0,0 +1,28 @@
+package openvpn
+
+import (
+	"log/slog"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
+)
+
+func (c *Client) AcceptClient(logger *slog.Logger, client state.ClientIdentifier) {
+	_, err := c.SendCommandf(`client-auth-nt %d %d`, client.Cid, client.Kid)
+	if err != nil {
+		logger.Warn(err.Error())
+	}
+}
+
+func (c *Client) AcceptClientWithToken(logger *slog.Logger, client state.ClientIdentifier, username string) {
+	_, err := c.SendCommandf("client-auth %d %d\npush \"auth-token-user %s\"\nEND", client.Cid, client.Kid, username)
+	if err != nil {
+		logger.Warn(err.Error())
+	}
+}
+
+func (c *Client) DenyClient(logger *slog.Logger, client state.ClientIdentifier, reason string) {
+	_, err := c.SendCommandf(`client-deny %d %d "%s"`, client.Cid, client.Kid, reason)
+	if err != nil {
+		logger.Warn(err.Error())
+	}
+}
diff --git a/internal/openvpn/client.go b/internal/openvpn/client.go
index 3f60266..75d6211 100644
--- a/internal/openvpn/client.go
+++ b/internal/openvpn/client.go
@@ -64,7 +64,12 @@ func (c *Client) clientConnect(client connection.Client) error {
 		return nil
 	}
 
-	session := state.New(client.Cid, client.Kid, client.Env["untrusted_ip"], client.Env["common_name"])
+	ClientIdentifier := state.ClientIdentifier{
+		Cid: client.Cid,
+		Kid: client.Kid,
+	}
+
+	session := state.New(ClientIdentifier, 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)
 	}
diff --git a/internal/state/state.go b/internal/state/state.go
index a800ce5..00a385e 100644
--- a/internal/state/state.go
+++ b/internal/state/state.go
@@ -9,19 +9,23 @@ import (
 )
 
 type State struct {
-	Cid        uint64    `json:"cid"`
-	Kid        uint64    `json:"kid"`
-	Ipaddr     string    `json:"ipaddr"`
-	CommonName string    `json:"commonName"`
-	Issued     time.Time `json:"issued"`
+	Client     ClientIdentifier `json:"c"`
+	Ipaddr     string           `json:"ip"`
+	CommonName string           `json:"cn"`
+	Issued     time.Time        `json:"iss"`
 
 	encoded string
 }
 
-func New(cid, kid uint64, ipaddr, commonName string) *State {
+type ClientIdentifier struct {
+	Cid      uint64 `json:"c"`
+	Kid      uint64 `json:"k"`
+	Filepath string `json:"fp"`
+}
+
+func New(client ClientIdentifier, ipaddr, commonName string) *State {
 	return &State{
-		Cid:        cid,
-		Kid:        kid,
+		Client:     client,
 		Ipaddr:     ipaddr,
 		CommonName: commonName,
 		Issued:     time.Now(),


Test patch:
diff --git a/cmd/root_test.go b/cmd/root_test.go
index 79bd2cd..2c93aef 100644
--- a/cmd/root_test.go
+++ b/cmd/root_test.go
@@ -48,7 +48,7 @@ func TestExecuteConfigInvalid(t *testing.T) {
 		{
 			"file not exists",
 			[]string{"", "--config=nonexists"},
-			"error loading config: error from file provider: open nonexists: no such file or directory",
+			"error loading config: file provider: open nonexists: no such file or directory",
 		},
 		{
 			"invalid log format",
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 5c2a590..b016f15 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -80,7 +80,7 @@ func TestValidate(t *testing.T) {
 		{"", 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{}, Bypass: config.OpenVpnBypass{}}, Log: config.Log{}}, "openvpn.addr is required"},
 		{"", 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{}}, ""},
@@ -90,11 +90,11 @@ func TestValidate(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			t.Parallel()
 
-			err := config.Validate(tt.conf)
+			err := config.Validate(config.ManagementClient, tt.conf)
 			if tt.err == "" {
 				assert.NoError(t, err)
 			} else if assert.Error(t, err) && tt.err != "-" {
-				assert.Equal(t, err.Error(), tt.err)
+				assert.Equal(t, tt.err, err.Error())
 			}
 		})
 	}
diff --git a/internal/oauth2/handler_test.go b/internal/oauth2/handler_test.go
index a79799c..3ec11b9 100644
--- a/internal/oauth2/handler_test.go
+++ b/internal/oauth2/handler_test.go
@@ -279,7 +279,7 @@ func TestHandler(t *testing.T) {
 
 			httpClient.Jar = jar
 
-			sessionState := state.New(0, 1, tt.ipaddr, "name")
+			sessionState := state.New(state.ClientIdentifier{Cid: 0, Kid: 1}, tt.ipaddr, "name")
 			err = sessionState.Encode(tt.conf.HTTP.Secret)
 			if !assert.NoError(t, err) {
 				return
diff --git a/internal/openvpn/main_test.go b/internal/openvpn/main_test.go
index d8a8827..b229536 100644
--- a/internal/openvpn/main_test.go
+++ b/internal/openvpn/main_test.go
@@ -241,8 +241,8 @@ func TestClientFull(t *testing.T) {
 					err := sessionState.Decode(tt.conf.HTTP.Secret)
 					assert.NoError(t, err)
 
-					assert.Equal(t, uint64(1), sessionState.Cid)
-					assert.Equal(t, uint64(2), sessionState.Kid)
+					assert.Equal(t, uint64(1), sessionState.Client.Cid)
+					assert.Equal(t, uint64(2), sessionState.Client.Kid)
 					assert.Equal(t, "test", sessionState.CommonName)
 					assert.Equal(t, "127.0.0.1", sessionState.Ipaddr)
 				}
diff --git a/internal/state/state_test.go b/internal/state/state_test.go
index 165ca03..e2013aa 100644
--- a/internal/state/state_test.go
+++ b/internal/state/state_test.go
@@ -14,14 +14,14 @@ func TestState(t *testing.T) {
 	encryptionKey := "0123456789101112"
 
 	for i := 1; i < 50; i++ {
-		token := state.New(1, 2, "127.0.0.1", "test")
+		token := state.New(state.ClientIdentifier{Cid: 1, Kid: 2}, "127.0.0.1", "test")
 		assert.NoError(t, token.Encode(encryptionKey))
 
 		encodedToken := state.NewEncoded(token.Encoded())
 		assert.NoError(t, encodedToken.Decode(encryptionKey))
 
-		assert.Equal(t, token.Cid, encodedToken.Cid)
-		assert.Equal(t, token.Kid, encodedToken.Kid)
+		assert.Equal(t, token.Client.Cid, encodedToken.Client.Cid)
+		assert.Equal(t, token.Client.Kid, encodedToken.Client.Kid)
 		assert.Equal(t, token.Ipaddr, encodedToken.Ipaddr)
 		assert.Equal(t, token.CommonName, encodedToken.CommonName)
 	}
@@ -32,7 +32,7 @@ func TestStateInvalid_Key(t *testing.T) {
 
 	encryptionKey := "01234567891011"
 
-	token := state.New(1, 2, "127.0.0.1", "test")
+	token := state.New(state.ClientIdentifier{Cid: 1, Kid: 2}, "127.0.0.1", "test")
 	assert.Error(t, token.Encode(encryptionKey), "crypto/aes: invalid key size 14")
 }
 
@@ -41,7 +41,7 @@ func TestStateInvalid_Future(t *testing.T) {
 
 	encryptionKey := "0123456789101112"
 
-	token := state.New(1, 2, "127.0.0.1", "test")
+	token := state.New(state.ClientIdentifier{Cid: 1, Kid: 2}, "127.0.0.1", "test")
 	token.Issued = time.Now().Add(time.Hour)
 
 	assert.NoError(t, token.Encode(encryptionKey))
@@ -53,7 +53,7 @@ func TestStateInvalid_TooOld(t *testing.T) {
 
 	encryptionKey := "0123456789101112"
 
-	token := state.New(1, 2, "127.0.0.1", "test")
+	token := state.New(state.ClientIdentifier{Cid: 1, Kid: 2}, "127.0.0.1", "test")
 	token.Issued = time.Now().Add(-1 * time.Hour)
 
 	assert.NoError(t, token.Encode(encryptionKey))