Repository: jkroepke/openvpn-auth-oauth2
Pull request #33 (2023-09-27 18:53:46)

Read CHANGELOG
Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>**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


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/33?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: `147 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`0b6a6f5`)](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/commit/0b6a6f5dad46cc27f22d6fb0e127d57f3e9c946f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) 68.40% compared to head [(`fadf779`)](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/33?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) 59.68%.

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


```diff
@@            Coverage Diff             @@
##             main      #33      +/-   ##
==========================================
- Coverage   68.40%   59.68%   -8.72%     
==========================================
  Files          11       15       +4     
  Lines         788      883      +95     
==========================================
- Hits          539      527      -12     
- Misses        220      324     +104     
- Partials       29       32       +3     
```


| [Files](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/33?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/oauth2/providers/generic/check.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/33?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-aW50ZXJuYWwvb2F1dGgyL3Byb3ZpZGVycy9nZW5lcmljL2NoZWNrLmdv) | `82.35% <100.00%> (ø)` | |
| [internal/oauth2/providers/generic/user.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/33?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-aW50ZXJuYWwvb2F1dGgyL3Byb3ZpZGVycy9nZW5lcmljL3VzZXIuZ28=) | `100.00% <ø> (ø)` | |
| [internal/openvpn/client\_connection.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/33?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-aW50ZXJuYWwvb3BlbnZwbi9jbGllbnRfY29ubmVjdGlvbi5nbw==) | `100.00% <100.00%> (ø)` | |
| [internal/state/state.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/33?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.21% <100.00%> (ø)` | |
| [internal/utils/strings.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/33?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-aW50ZXJuYWwvdXRpbHMvc3RyaW5ncy5nbw==) | `100.00% <100.00%> (ø)` | |
| [internal/utils/url.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/33?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-aW50ZXJuYWwvdXRpbHMvdXJsLmdv) | `100.00% <100.00%> (ø)` | |
| [internal/oauth2/provider.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/33?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% <95.65%> (+2.40%)` | :arrow_up: |
| [internal/oauth2/providers/generic/main.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/33?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% <0.00%> (ø)` | |
| [internal/config/config.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/33?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% <76.19%> (-4.21%)` | :arrow_down: |
| [internal/oauth2/providers/generic/endpoints.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/33?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%> (ø)` | |
| ... and [4 more](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/33?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/33?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b392f98..e87b636 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [1.6.0] - 2023-09-28
+
+### Added
+
+- Implement client ip check between http client and vpn client
+
+### Changes
+
+- Provider oidc is now named generic
+
 ## [1.5.1] - 2023-09-25
 
 ### Fixed
@@ -82,6 +92,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - First release
 
 [Unreleased]: https://github.com/jkroepke/openvpn-auth-oauth2/compare/v1.5.1...HEAD
+[1.6.0]: https://github.com/jkroepke/openvpn-auth-oauth2/releases/tag/v1.6.0
 [1.5.1]: https://github.com/jkroepke/openvpn-auth-oauth2/releases/tag/v1.5.1
 [1.5.0]: https://github.com/jkroepke/openvpn-auth-oauth2/releases/tag/v1.5.0
 [1.4.0]: https://github.com/jkroepke/openvpn-auth-oauth2/releases/tag/v1.4.0
diff --git a/cmd/root.go b/cmd/root.go
index 49d2433..9b5199d 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -5,17 +5,21 @@ import (
 	"log/slog"
 	"net/http"
 	"os"
+	"os/signal"
 	"runtime"
 	"strings"
+	"syscall"
 
 	"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/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"
 	"go.uber.org/zap"
 	"go.uber.org/zap/exp/zapslog"
 )
@@ -28,7 +32,7 @@ func Execute(version, commit, date string) int {
 
 	f := config.FlagSet()
 	if err := f.Parse(os.Args[1:]); err != nil {
-		logger.Error(fmt.Sprintf("error parsing cli args: %v", err))
+		logger.Error(utils.StringConcat("error parsing cli args: ", err.Error()))
 		return 1
 	}
 
@@ -40,13 +44,13 @@ func Execute(version, commit, date string) int {
 	configFile, _ := f.GetString("config")
 	if configFile != "" {
 		if err := k.Load(file.Provider(configFile), yaml.Parser()); err != nil {
-			logger.Error(fmt.Sprintf("error loading config: %v", err))
+			logger.Error(utils.StringConcat("error loading config: ", err.Error()))
 			return 1
 		}
 	}
 
 	if err := k.Load(posflag.Provider(f, ".", k), nil); err != nil {
-		logger.Error(fmt.Sprintf("error loading config: %v", err))
+		logger.Error(utils.StringConcat("error loading config: ", err.Error()))
 		return 1
 	}
 
@@ -61,19 +65,33 @@ func Execute(version, commit, date string) int {
 		// Otherwise, return the plain string.
 		return key, v
 	}), nil); err != nil {
-		logger.Error(fmt.Sprintf("error loading config: %v", err))
+		logger.Error(utils.StringConcat("error loading config: ", err.Error()))
 		return 1
 	}
 
 	var conf config.Config
-	if err := k.Unmarshal("", &conf); err != nil {
-		logger.Error(fmt.Sprintf("error loading config: %v", err))
+	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 {
+		logger.Error(utils.StringConcat("error loading config: ", err.Error()))
 		return 1
 	}
 
 	logger, err := configureLogger(&conf)
 	if err != nil {
-		logger.Error(fmt.Sprintf("error configure logger: %v", err))
+		logger.Error(utils.StringConcat("error configure logger: ", err.Error()))
 		return 1
 	}
 	defer logger.Sync() //nolint:errcheck
@@ -81,7 +99,7 @@ func Execute(version, commit, date string) int {
 	sl := slog.New(zapslog.NewHandler(logger.Core(), nil))
 
 	if err := config.Validate(&conf); err != nil {
-		sl.Error(fmt.Sprintf("error validating config: %v", err))
+		sl.Error(utils.StringConcat("error validating config: ", err.Error()))
 		return 1
 	}
 
@@ -108,13 +126,13 @@ func Execute(version, commit, date string) int {
 		}
 
 		if conf.Http.Tls {
-			sl.Info(fmt.Sprintf("HTTPS server listen on %s with base url %s", conf.Http.Listen, conf.Http.BaseUrl))
+			sl.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 {
 				sl.Error(err.Error())
 				done <- 1
 			}
 		} else {
-			sl.Info(fmt.Sprintf("HTTP server listen on %s with base url %s", conf.Http.Listen, conf.Http.BaseUrl))
+			sl.Info(utils.StringConcat("HTTP server listen on ", conf.Http.Listen, " with base url ", conf.Http.BaseUrl.String()))
 			if err := server.ListenAndServe(); err != nil {
 				sl.Error(err.Error())
 				done <- 1
@@ -122,13 +140,6 @@ func Execute(version, commit, date string) int {
 		}
 	}()
 
-	sigs := make(chan os.Signal, 1)
-	go func() {
-		sig := <-sigs
-		logger.Info(fmt.Sprintf("reciving signal: %s", sig.String()))
-		openvpnClient.Shutdown()
-	}()
-
 	go func() {
 		defer openvpnClient.Shutdown()
 		if err := openvpnClient.Connect(); err != nil {
@@ -138,7 +149,17 @@ func Execute(version, commit, date string) int {
 		done <- 0
 	}()
 
-	return <-done
+	termCh := make(chan os.Signal, 1)
+	signal.Notify(termCh, syscall.SIGINT, syscall.SIGTERM)
+
+	select {
+	case returnCode := <-done:
+		return returnCode
+	case sig := <-termCh:
+		sl.Info(utils.StringConcat("receiving signal: ", sig.String()))
+		openvpnClient.Shutdown()
+		return 0
+	}
 }
 
 func configureLogger(conf *config.Config) (*zap.Logger, error) {
diff --git a/go.mod b/go.mod
index 7007516..782eca2 100644
--- a/go.mod
+++ b/go.mod
@@ -35,9 +35,9 @@ require (
 	github.com/muhlemmer/httpforwarded v0.1.0 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/rs/cors v1.10.0 // indirect
-	go.opentelemetry.io/otel v1.17.0 // indirect
-	go.opentelemetry.io/otel/metric v1.17.0 // indirect
-	go.opentelemetry.io/otel/trace v1.17.0 // indirect
+	go.opentelemetry.io/otel v1.18.0 // indirect
+	go.opentelemetry.io/otel/metric v1.18.0 // indirect
+	go.opentelemetry.io/otel/trace v1.18.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	golang.org/x/crypto v0.13.0 // indirect
 	golang.org/x/sys v0.12.0 // indirect
diff --git a/go.sum b/go.sum
index 3e9deb0..8ce0537 100644
--- a/go.sum
+++ b/go.sum
@@ -67,10 +67,16 @@ github.com/zitadel/oidc/v2 v2.11.0 h1:Am4/yQr4iiM5bznRgF3FOp+wLdKx2gzSU73uyI9vvB
 github.com/zitadel/oidc/v2 v2.11.0/go.mod h1:enFSVBQI6aE0TEB1ntjXs9r6O6DEosxX4uhEBLBVD8o=
 go.opentelemetry.io/otel v1.17.0 h1:MW+phZ6WZ5/uk2nd93ANk/6yJ+dVrvNWUjGhnnFU5jM=
 go.opentelemetry.io/otel v1.17.0/go.mod h1:I2vmBGtFaODIVMBSTPVDlJSzBDNf93k60E6Ft0nyjo0=
+go.opentelemetry.io/otel v1.18.0 h1:TgVozPGZ01nHyDZxK5WGPFB9QexeTMXEH7+tIClWfzs=
+go.opentelemetry.io/otel v1.18.0/go.mod h1:9lWqYO0Db579XzVuCKFNPDl4s73Voa+zEck3wHaAYQI=
 go.opentelemetry.io/otel/metric v1.17.0 h1:iG6LGVz5Gh+IuO0jmgvpTB6YVrCGngi8QGm+pMd8Pdc=
 go.opentelemetry.io/otel/metric v1.17.0/go.mod h1:h4skoxdZI17AxwITdmdZjjYJQH5nzijUUjm+wtPph5o=
+go.opentelemetry.io/otel/metric v1.18.0 h1:JwVzw94UYmbx3ej++CwLUQZxEODDj/pOuTCvzhtRrSQ=
+go.opentelemetry.io/otel/metric v1.18.0/go.mod h1:nNSpsVDjWGfb7chbRLUNW+PBNdcSTHD4Uu5pfFMOI0k=
 go.opentelemetry.io/otel/trace v1.17.0 h1:/SWhSRHmDPOImIAetP1QAeMnZYiQXrTy4fMMYOdSKWQ=
 go.opentelemetry.io/otel/trace v1.17.0/go.mod h1:I/4vKTgFclIsXRVucpH25X0mpFSczM7aHeaz0ZBLWjY=
+go.opentelemetry.io/otel/trace v1.18.0 h1:NY+czwbHbmndxojTEKiSMHkG2ClNH2PwmcHrdo0JY10=
+go.opentelemetry.io/otel/trace v1.18.0/go.mod h1:T2+SGJGuYZY3bjj5rgh/hN7KIrlpWC5nS8Mjvzckz+0=
 go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
 go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
diff --git a/internal/config/config.go b/internal/config/config.go
index c00defb..297607b 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -9,6 +9,7 @@ import (
 	"reflect"
 	"slices"
 
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
 	flag "github.com/spf13/pflag"
 )
 
@@ -21,14 +22,19 @@ type Config struct {
 }
 
 type Http struct {
-	Listen               string `koanf:"listen"`
-	CertFile             string `koanf:"cert"`
-	KeyFile              string `koanf:"key"`
-	Tls                  bool   `koanf:"tls"`
-	BaseUrl              string `koanf:"baseurl"`
-	Secret               string `koanf:"secret"`
-	CallbackTemplate     *template.Template
-	CallbackTemplatePath string `koanf:"callback_template_path"`
+	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 {
@@ -37,7 +43,7 @@ type Log struct {
 }
 
 type OpenVpn struct {
-	Addr          string         `koanf:"addr"`
+	Addr          *url.URL       `koanf:"addr"`
 	Password      string         `koanf:"password"`
 	Bypass        *OpenVpnBypass `koanf:"bypass"`
 	AuthTokenUser bool           `koanf:"auth-token-user"`
@@ -48,13 +54,14 @@ type OpenVpnBypass struct {
 }
 
 type OAuth2 struct {
-	Issuer    string           `koanf:"issuer"`
-	Provider  string           `koanf:"provider"`
-	Endpoints *OAuth2Endpoints `koanf:"endpoint"`
-	Client    *OAuth2Client    `koanf:"client"`
-	Scopes    []string         `koanf:"scopes"`
-	Pkce      bool             `koanf:"pkce"`
-	Validate  *OAuth2Validate  `koanf:"validate"`
+	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 {
@@ -63,9 +70,9 @@ type OAuth2Client struct {
 }
 
 type OAuth2Endpoints struct {
-	Discovery string `koanf:"discovery"`
-	Auth      string `koanf:"auth"`
-	Token     string `koanf:"token"`
+	Discovery *url.URL `koanf:"discovery"`
+	Auth      *url.URL `koanf:"auth"`
+	Token     *url.URL `koanf:"token"`
 }
 
 type OAuth2Validate struct {
@@ -79,7 +86,7 @@ type OAuth2Validate struct {
 func FlagSet() *flag.FlagSet {
 	f := flag.NewFlagSet("openvpn-auth-oauth2", flag.ContinueOnError)
 	f.Usage = func() {
-		_, _ = fmt.Fprintln(os.Stderr, "Usage of openvpn-auth-oauth2:")
+		fmt.Println("Usage of openvpn-auth-oauth2:")
 		f.PrintDefaults()
 		os.Exit(0)
 	}
@@ -93,13 +100,16 @@ func FlagSet() *flag.FlagSet {
 	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.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", "oidc", "oauth2 provider. (env: CONFIG_OAUTH2_PROVIDER)")
+	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)")
@@ -124,29 +134,37 @@ func Validate(conf *Config) error {
 		"log":     conf.Log,
 	} {
 		if reflect.ValueOf(value).IsNil() {
-			return fmt.Errorf("%s is nil", key)
+			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 fmt.Errorf("%s is nil", key)
+			return errors.New(utils.StringConcat(key, " is nil"))
 		}
 	}
 
 	for key, value := range map[string]string{
-		"http.baseurl":     conf.Http.BaseUrl,
 		"http.secret":      conf.Http.Secret,
-		"oauth2.issuer":    conf.Oauth2.Issuer,
 		"oauth2.client.id": conf.Oauth2.Client.Id,
 	} {
 		if value == "" {
-			return fmt.Errorf("%s is required", key)
+			return errors.New(utils.StringConcat(key, " is required"))
+		}
+	}
+
+	for key, value := range map[string]*url.URL{
+		"http.baseurl":  conf.Http.BaseUrl,
+		"oauth2.issuer": conf.Oauth2.Issuer,
+	} {
+		if utils.IsUrlEmpty(value) {
+			return errors.New(utils.StringConcat(key, " is required"))
 		}
 	}
 
@@ -154,52 +172,25 @@ func Validate(conf *Config) error {
 		return errors.New("http.secret requires a length of 16, 24 or 32")
 	}
 
-	if uri, err := url.Parse(conf.OpenVpn.Addr); err != nil {
-		return fmt.Errorf("openvpn.addr: invalid URL. error: %s", err)
-	} else if !slices.Contains([]string{"tcp", "unix"}, uri.Scheme) {
+	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")
 	}
 
-	if uri, err := url.Parse(conf.Http.BaseUrl); err != nil {
-		return fmt.Errorf("http.baseurl: invalid URL. error: %s", err)
-	} else if uri.Host == "" {
-		return errors.New("http.baseurl: invalid URL. empty hostname")
-	}
-
-	for key, value := range map[string]string{
+	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,
 	} {
-		if value == "" {
+		if utils.IsUrlEmpty(uri) {
 			continue
 		}
 
-		uri, err := url.Parse(value)
-		if err != nil {
-			return fmt.Errorf("%s: invalid URL. error: %s", key, err)
-		}
-
 		if !slices.Contains([]string{"http", "https"}, uri.Scheme) {
-			return fmt.Errorf("%s: invalid URL. only http:// or https:// scheme supported", key)
+			return errors.New(utils.StringConcat(key, ": invalid URL. only http:// or https:// scheme supported"))
 		}
 	}
 
-	if (conf.Oauth2.Endpoints.Token != "" && conf.Oauth2.Endpoints.Auth == "") ||
-		(conf.Oauth2.Endpoints.Token == "" && conf.Oauth2.Endpoints.Auth != "") {
-		return errors.New("both oauth2.endpoints.tokenUrl and oauth2.endpoints.authUrl are required")
-	}
-
-	if conf.Http.CallbackTemplatePath != "" {
-		tmpl, err := template.New("callback").ParseFiles(conf.Http.CallbackTemplatePath)
-		if err != nil {
-			return fmt.Errorf("http.callbackTemplatePath: invalid template: %s", err)
-		}
-
-		conf.Http.CallbackTemplate = tmpl
-	}
-
 	return nil
 }
diff --git a/internal/config/decode_hooks.go b/internal/config/decode_hooks.go
new file mode 100644
index 0000000..b67f01d
--- /dev/null
+++ b/internal/config/decode_hooks.go
@@ -0,0 +1,65 @@
+package config
+
+import (
+	"errors"
+	"html/template"
+	"net/url"
+	"reflect"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
+	"github.com/mitchellh/mapstructure"
+)
+
+func StringToUrlHookFunc() mapstructure.DecodeHookFunc {
+	return func(
+		f reflect.Type,
+		t reflect.Type,
+		data interface{}) (interface{}, error) {
+		if f.Kind() != reflect.String {
+			return data, nil
+		}
+		if t != reflect.TypeOf(url.URL{}) {
+			return data, nil
+		}
+
+		// Convert it by parsing
+		uri, err := url.Parse(data.(string))
+		if err != nil {
+			return nil, err
+		}
+
+		if uri.String() == "" {
+			return uri, nil
+		}
+
+		if uri.Scheme == "" {
+			return nil, errors.New(utils.StringConcat("invalid URL ", data.(string), ": empty scheme"))
+		}
+
+		if uri.Host == "" && uri.Path == "" {
+			return nil, errors.New(utils.StringConcat("invalid URL ", data.(string), ": empty hostname"))
+		}
+
+		return uri, nil
+	}
+}
+func StringToTemplateHookFunc() mapstructure.DecodeHookFunc {
+	return func(
+		f reflect.Type,
+		t reflect.Type,
+		data interface{}) (interface{}, error) {
+		if f.Kind() != reflect.String {
+			return data, nil
+		}
+
+		if t != reflect.TypeOf(template.Template{}) {
+			return data, nil
+		}
+
+		if data.(string) == "" {
+			return template.Template{}, nil
+		}
+
+		return template.New("callback").ParseFiles(data.(string))
+	}
+}
diff --git a/internal/oauth2/handler.go b/internal/oauth2/handler.go
index a4b6a5d..ee54405 100644
--- a/internal/oauth2/handler.go
+++ b/internal/oauth2/handler.go
@@ -3,32 +3,30 @@ package oauth2
 import (
 	"context"
 	"encoding/base64"
-	"fmt"
 	"log/slog"
+	"net"
 	"net/http"
-	"net/url"
 	"strings"
 	"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/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 {
-	baseUrl, _ := url.Parse(conf.Http.BaseUrl)
-
 	mux := http.NewServeMux()
 	mux.Handle("/", http.NotFoundHandler())
-	mux.Handle(strings.TrimSuffix(baseUrl.Path, "/")+"/oauth2/start", oauth2Start(logger, oidcProvider, conf))
-	mux.Handle(strings.TrimSuffix(baseUrl.Path, "/")+"/oauth2/callback", oauth2Callback(logger, oidcProvider, conf, openvpnClient))
+	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))
 
 	return mux
 }
 
-func oauth2Start(logger *slog.Logger, oidcProvider *Provider, conf *config.Config) http.Handler {
+func oauth2Start(logger *slog.Logger, oidcProvider *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 == "" {
@@ -38,16 +36,50 @@ func oauth2Start(logger *slog.Logger, oidcProvider *Provider, conf *config.Confi
 
 		session := state.NewEncoded(sessionState)
 		if err := session.Decode(conf.Http.Secret); err != nil {
-			logger.Warn(fmt.Sprintf("invalid state: %s", err.Error()),
-				"common_name", session.CommonName,
-				"cid", session.Cid,
-				"kid", session.Kid,
-			)
+			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,
+				)
+
+				openvpnClient.SendCommandf(`client-deny %d %d "%s"`, session.Cid, session.Kid, "client rejected")
+
+				w.WriteHeader(http.StatusForbidden)
+				return
+			}
+		}
+
 		logger.Info("initialize authorization via oauth2",
 			"common_name", session.CommonName,
 			"cid", session.Cid,
@@ -76,7 +108,7 @@ func oauth2Callback(logger *slog.Logger, oidcProvider *Provider, conf *config.Co
 			return
 		}
 
-		user, err := oidcProvider.Connector.GetUser(ctx, tokens)
+		user, err := oidcProvider.OidcProvider.GetUser(ctx, tokens)
 		if err != nil {
 			logger.Error(err.Error(),
 				"common_name", session.CommonName,
@@ -84,11 +116,12 @@ func oauth2Callback(logger *slog.Logger, oidcProvider *Provider, conf *config.Co
 				"kid", session.Kid,
 			)
 
-			openvpnClient.SendCommand("client-deny %d %d \"%s\"", session.Cid, session.Kid, "unable to fetch user data")
+			openvpnClient.SendCommandf(`client-deny %d %d "%s"`, session.Cid, session.Kid, "unable to fetch user data")
+
 			return
 		}
 
-		if err := oidcProvider.Connector.CheckUser(ctx, session, user, tokens); err != nil {
+		if err := oidcProvider.OidcProvider.CheckUser(ctx, session, user, tokens); err != nil {
 			logger.Warn(err.Error(),
 				"subject", user.Subject,
 				"preferred_username", user.PreferredUsername,
@@ -97,9 +130,9 @@ func oauth2Callback(logger *slog.Logger, oidcProvider *Provider, conf *config.Co
 				"kid", session.Kid,
 			)
 
-			openvpnClient.SendCommand("client-deny %d %d \"%s\"", session.Cid, session.Kid, "client rejected")
+			openvpnClient.SendCommandf(`client-deny %d %d "%s"`, session.Cid, session.Kid, "client rejected")
 
-			if conf.Http.CallbackTemplate == nil {
+			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{
@@ -133,12 +166,12 @@ func oauth2Callback(logger *slog.Logger, oidcProvider *Provider, conf *config.Co
 			}
 
 			b64Username := base64.StdEncoding.EncodeToString([]byte(username))
-			openvpnClient.SendCommand("client-auth %d %d\npush \"auth-token-user %s\"\nEND", session.Cid, session.Kid, b64Username)
+			openvpnClient.SendCommandf("client-auth %d %d\npush \"auth-token-user %s\"\nEND", session.Cid, session.Kid, b64Username)
 		} else {
-			openvpnClient.SendCommand(`client-auth-nt %d %d`, session.Cid, session.Kid)
+			openvpnClient.SendCommandf("client-auth-nt %d %d", session.Cid, session.Kid)
 		}
 
-		if conf.Http.CallbackTemplate == nil {
+		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)
diff --git a/internal/oauth2/provider.go b/internal/oauth2/provider.go
index 8823927..76b4cb3 100644
--- a/internal/oauth2/provider.go
+++ b/internal/oauth2/provider.go
@@ -2,55 +2,52 @@ package oauth2
 
 import (
 	"context"
-	"fmt"
+	"errors"
 	"log/slog"
 	"net/http"
-	"net/url"
 	"strings"
 
 	"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/oauth2/providers/oidc"
 	"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"
-	token "github.com/zitadel/oidc/v2/pkg/oidc"
+	"github.com/zitadel/oidc/v2/pkg/oidc"
 	"golang.org/x/oauth2"
 )
 
 type Provider struct {
 	rp.RelyingParty
-	Connector
+	OidcProvider
 }
 
-type Connector interface {
-	CheckUser(ctx context.Context, session *state.State, user *types.UserData, tokens *token.Tokens[*token.IDTokenClaims]) error
-	GetUser(ctx context.Context, tokens *token.Tokens[*token.IDTokenClaims]) (*types.UserData, error)
+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) {
-	baseUrl, err := url.Parse(conf.Http.BaseUrl)
-	if err != nil {
-		return nil, fmt.Errorf("http.baseurl: %v", err)
-	}
-
-	tokenValidator, err := NewTokenValidateProvider(conf)
+	oidcProvider, err := NewOidcProvider(conf)
 	if err != nil {
 		return nil, err
 	}
 
-	redirectURI := fmt.Sprintf("%s/oauth2/callback", strings.TrimSuffix(conf.Http.BaseUrl, "/"))
+	redirectURI := utils.StringConcat(strings.TrimSuffix(conf.Http.BaseUrl.String(), "/"), "/oauth2/callback")
 
 	cookieKey := []byte(conf.Http.Secret)
 	cookieOpt := []httphelper.CookieHandlerOpt{
 		httphelper.WithMaxAge(1800),
-		httphelper.WithPath(fmt.Sprintf("%s/oauth2", strings.TrimSuffix(baseUrl.Path, "/"))),
-		httphelper.WithDomain(baseUrl.Hostname()),
+		httphelper.WithPath(utils.StringConcat(strings.TrimSuffix(conf.Http.BaseUrl.Path, "/"), "/oauth2")),
+		httphelper.WithDomain(conf.Http.BaseUrl.Hostname()),
 	}
 
-	if baseUrl.Scheme == "http" {
+	if conf.Http.BaseUrl.Scheme == "http" {
 		cookieOpt = append(cookieOpt, httphelper.WithUnsecure())
 	}
 
@@ -79,16 +76,21 @@ func NewProvider(logger *slog.Logger, conf *config.Config) (*Provider, error) {
 		options = append(options, rp.WithPKCE(cookieHandler))
 	}
 
-	if conf.Oauth2.Endpoints.Auth == "" && conf.Oauth2.Endpoints.Token == "" {
-		if conf.Oauth2.Endpoints.Discovery != "" {
-			logger.Info(fmt.Sprintf("discover OIDC auto configuration for issuer %s with custom discovery url %s", conf.Oauth2.Issuer, conf.Oauth2.Endpoints.Discovery))
-			options = append(options, rp.WithCustomDiscoveryUrl(conf.Oauth2.Endpoints.Discovery))
+	endpoints, err := oidcProvider.GetEndpoints(conf)
+	if err != nil {
+		return nil, 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(fmt.Sprintf("discover OIDC auto configuration for issuer %s", conf.Oauth2.Issuer))
+			logger.Info(utils.StringConcat("discover OIDC auto configuration with provider ", oidcProvider.GetName(), "for issuer ", conf.Oauth2.Issuer.String()))
 		}
 
 		relayingParty, err := rp.NewRelyingPartyOIDC(
-			conf.Oauth2.Issuer,
+			conf.Oauth2.Issuer.String(),
 			conf.Oauth2.Client.Id,
 			conf.Oauth2.Client.Secret,
 			redirectURI,
@@ -102,21 +104,18 @@ func NewProvider(logger *slog.Logger, conf *config.Config) (*Provider, error) {
 
 		return &Provider{
 			RelyingParty: relayingParty,
-			Connector:    tokenValidator,
+			OidcProvider: oidcProvider,
 		}, nil
 	}
 
-	logger.Info(fmt.Sprintf("manually configure oauth2 provider with endpoints %s and %s", conf.Oauth2.Endpoints.Auth, conf.Oauth2.Endpoints.Token))
+	logger.Info(utils.StringConcat("manually configure oauth2 provider with provider ", oidcProvider.GetName(), " and endpoints ", endpoints.AuthURL, " and ", endpoints.TokenURL))
 
 	rpConfig := &oauth2.Config{
 		ClientID:     conf.Oauth2.Client.Id,
 		ClientSecret: conf.Oauth2.Client.Secret,
 		RedirectURL:  redirectURI,
 		Scopes:       conf.Oauth2.Scopes,
-		Endpoint: oauth2.Endpoint{
-			AuthURL:  conf.Oauth2.Endpoints.Auth,
-			TokenURL: conf.Oauth2.Endpoints.Token,
-		},
+		Endpoint:     *endpoints,
 	}
 
 	relayingParty, err := rp.NewRelyingPartyOAuth(rpConfig, options...)
@@ -126,17 +125,17 @@ func NewProvider(logger *slog.Logger, conf *config.Config) (*Provider, error) {
 
 	return &Provider{
 		RelyingParty: relayingParty,
-		Connector:    tokenValidator,
+		OidcProvider: oidcProvider,
 	}, nil
 }
 
-func NewTokenValidateProvider(conf *config.Config) (Connector, error) {
+func NewOidcProvider(conf *config.Config) (OidcProvider, error) {
 	switch conf.Oauth2.Provider {
-	case "oidc":
-		return oidc.NewProvider(conf), nil
+	case "generic":
+		return generic.NewProvider(conf), nil
 	case "github":
 		return github.NewProvider(conf), nil
 	default:
-		return nil, fmt.Errorf("unknown oauth2 provider: %s", conf.Oauth2.Provider)
+		return nil, errors.New(utils.StringConcat("unknown oauth2 provider: ", conf.Oauth2.Provider))
 	}
 }
diff --git a/internal/oauth2/providers/oidc/check.go b/internal/oauth2/providers/generic/check.go
similarity index 77%
rename from internal/oauth2/providers/oidc/check.go
rename to internal/oauth2/providers/generic/check.go
index 4bfa9d0..c010f9b 100644
--- a/internal/oauth2/providers/oidc/check.go
+++ b/internal/oauth2/providers/generic/check.go
@@ -1,9 +1,8 @@
-package oidc
+package generic
 
 import (
 	"context"
 	"errors"
-	"fmt"
 	"slices"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
@@ -43,7 +42,7 @@ func (p *Provider) CheckGroups(tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
 
 	for _, group := range p.Conf.Oauth2.Validate.Groups {
 		if !slices.Contains(tokenGroupsList, group) {
-			return fmt.Errorf("missing required group %s", group)
+			return errors.New(utils.StringConcat("missing required group ", group))
 		}
 	}
 
@@ -64,7 +63,7 @@ func (p *Provider) CheckRoles(tokens *oidc.Tokens[*oidc.IDTokenClaims]) error {
 
 	for _, role := range p.Conf.Oauth2.Validate.Roles {
 		if !slices.Contains(tokenRolesList, role) {
-			return fmt.Errorf("missing required role %s", role)
+			return errors.New(utils.StringConcat("missing required role ", role))
 		}
 	}
 
@@ -75,13 +74,13 @@ func (p *Provider) CheckCommonName(session *state.State, tokens *oidc.Tokens[*oi
 		return nil
 	}
 
-	tokenCommonName, ok := tokens.IDTokenClaims.Claims[p.Conf.Oauth2.Validate.CommonName]
+	tokenCommonName, ok := tokens.IDTokenClaims.Claims[p.Conf.Oauth2.Validate.CommonName].(string)
 	if !ok {
-		return fmt.Errorf("missing %s claim", p.Conf.Oauth2.Validate.CommonName)
+		return errors.New(utils.StringConcat("missing ", p.Conf.Oauth2.Validate.CommonName, " claim"))
 	}
 
 	if tokenCommonName != session.CommonName {
-		return fmt.Errorf("common_name mismatch: openvpn client: %s - oidc token: %s", tokenCommonName, session.CommonName)
+		return errors.New(utils.StringConcat("common_name mismatch: openvpn client: ", tokenCommonName, " - oidc token: ", session.CommonName))
 	}
 
 	return nil
@@ -91,13 +90,13 @@ func (p *Provider) CheckIpAddress(session *state.State, tokens *oidc.Tokens[*oid
 		return nil
 	}
 
-	tokenIpaddr, ok := tokens.IDTokenClaims.Claims["ipaddr"]
+	tokenIpaddr, ok := tokens.IDTokenClaims.Claims["ipaddr"].(string)
 	if !ok {
 		return errors.New("missing ipaddr claim")
 	}
 
 	if tokenIpaddr != session.Ipaddr {
-		return fmt.Errorf("ipaddr mismatch: openvpn client: %s - oidc token: %s", tokenIpaddr, session.Ipaddr)
+		return errors.New(utils.StringConcat("ipaddr mismatch: openvpn client: ", tokenIpaddr, " - oidc token: ", session.Ipaddr))
 	}
 
 	return nil
diff --git a/internal/oauth2/providers/generic/endpoints.go b/internal/oauth2/providers/generic/endpoints.go
new file mode 100644
index 0000000..5b8ff72
--- /dev/null
+++ b/internal/oauth2/providers/generic/endpoints.go
@@ -0,0 +1,25 @@
+package generic
+
+import (
+	"errors"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
+	"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
+	}
+
+	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")
+	}
+
+	return &oauth2.Endpoint{
+		AuthURL:  conf.Oauth2.Endpoints.Auth.String(),
+		TokenURL: conf.Oauth2.Endpoints.Token.String(),
+	}, nil
+}
diff --git a/internal/oauth2/providers/oidc/main.go b/internal/oauth2/providers/generic/main.go
similarity index 68%
rename from internal/oauth2/providers/oidc/main.go
rename to internal/oauth2/providers/generic/main.go
index f3d4660..36200ba 100644
--- a/internal/oauth2/providers/oidc/main.go
+++ b/internal/oauth2/providers/generic/main.go
@@ -1,9 +1,11 @@
-package oidc
+package generic
 
 import (
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
 )
 
+const Name = "generic"
+
 type Provider struct {
 	Conf *config.Config
 }
@@ -13,3 +15,7 @@ func NewProvider(conf *config.Config) *Provider {
 		Conf: conf,
 	}
 }
+
+func (p *Provider) GetName() string {
+	return Name
+}
diff --git a/internal/oauth2/providers/oidc/user.go b/internal/oauth2/providers/generic/user.go
similarity index 97%
rename from internal/oauth2/providers/oidc/user.go
rename to internal/oauth2/providers/generic/user.go
index 61ac4f3..48d08d2 100644
--- a/internal/oauth2/providers/oidc/user.go
+++ b/internal/oauth2/providers/generic/user.go
@@ -1,4 +1,4 @@
-package oidc
+package generic
 
 import (
 	"context"
diff --git a/internal/oauth2/providers/github/api.go b/internal/oauth2/providers/github/api.go
index 265404a..b9216ed 100644
--- a/internal/oauth2/providers/github/api.go
+++ b/internal/oauth2/providers/github/api.go
@@ -3,9 +3,11 @@ package github
 import (
 	"context"
 	"encoding/json"
-	"fmt"
+	"errors"
 	"net/http"
 	"regexp"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
 )
 
 // Pagination URL patterns
@@ -21,14 +23,14 @@ func get[T any](ctx context.Context, accessToken string, apiUrl string, t *T) (s
 		return "", err
 	}
 
-	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken))
+	req.Header.Add("Authorization", utils.StringConcat("Bearer ", accessToken))
 	req.Header.Add("Accept", "application/json")
 
 	resp, err := http.DefaultClient.Do(req)
 	if err != nil {
 		return "", err
 	} else if resp.StatusCode != http.StatusOK {
-		return "", fmt.Errorf("error from github api %s, http status code: %d", apiUrl, resp.StatusCode)
+		return "", errors.New(utils.StringConcat("error from github api ", apiUrl, ", http status code: ", resp.Status))
 	}
 
 	defer resp.Body.Close()
diff --git a/internal/oauth2/providers/github/check.go b/internal/oauth2/providers/github/check.go
index 245349c..90d616b 100644
--- a/internal/oauth2/providers/github/check.go
+++ b/internal/oauth2/providers/github/check.go
@@ -2,10 +2,10 @@ package github
 
 import (
 	"context"
-	"fmt"
 
 	"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/oidc"
 )
 
@@ -68,7 +68,7 @@ func (p *Provider) CheckUser(ctx context.Context, _ *state.State, _ *types.UserD
 				return err
 			}
 			for _, team := range teams {
-				roles = append(roles, fmt.Sprintf("%s:%s", team.Org.Login, team.Slug))
+				roles = append(roles, utils.StringConcat(team.Org.Login, ":", team.Slug))
 			}
 
 			if apiUrl == "" {
diff --git a/internal/oauth2/providers/github/endpoints.go b/internal/oauth2/providers/github/endpoints.go
new file mode 100644
index 0000000..e27ddad
--- /dev/null
+++ b/internal/oauth2/providers/github/endpoints.go
@@ -0,0 +1,26 @@
+package github
+
+import (
+	"errors"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
+	"golang.org/x/oauth2"
+	"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
+	}
+
+	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")
+	}
+
+	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 b753d91..477ed88 100644
--- a/internal/oauth2/providers/github/main.go
+++ b/internal/oauth2/providers/github/main.go
@@ -2,15 +2,21 @@ package github
 
 import (
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/providers/oidc"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2/providers/generic"
 )
 
+const Name = "github"
+
 type Provider struct {
-	*oidc.Provider
+	*generic.Provider
 }
 
 func NewProvider(conf *config.Config) *Provider {
 	return &Provider{
-		Provider: oidc.NewProvider(conf),
+		Provider: generic.NewProvider(conf),
 	}
 }
+
+func (p *Provider) GetName() string {
+	return Name
+}
diff --git a/internal/openvpn/client.go b/internal/openvpn/client.go
index 1090c0c..2743e23 100644
--- a/internal/openvpn/client.go
+++ b/internal/openvpn/client.go
@@ -2,6 +2,8 @@ package openvpn
 
 import (
 	"bufio"
+	"bytes"
+	"context"
 	"encoding/base64"
 	"errors"
 	"fmt"
@@ -10,17 +12,22 @@ import (
 	"net/url"
 	"slices"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
 	"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
-	reader *bufio.Reader
-	logger *slog.Logger
+	conf    *config.Config
+	conn    net.Conn
+	scanner *bufio.Scanner
+	logger  *slog.Logger
+
+	mu     sync.Mutex
+	closed bool
 
 	clientsCh         chan *ClientConnection
 	commandResponseCh chan string
@@ -29,11 +36,21 @@ type Client struct {
 	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),
@@ -43,70 +60,83 @@ func NewClient(logger *slog.Logger, conf *config.Config) *Client {
 }
 
 func (c *Client) Connect() error {
-	uri, err := url.Parse(c.conf.OpenVpn.Addr)
-	c.logger.Info(fmt.Sprintf("connect to openvpn management interface %s", uri.String()))
-	if err != nil {
-		return fmt.Errorf("unable to parse openvpn addr as URI: %v", err)
-	}
-
-	switch uri.Scheme {
+	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(uri.Scheme, uri.Host)
+		c.conn, err = net.Dial(c.conf.OpenVpn.Addr.Scheme, c.conf.OpenVpn.Addr.Host)
 	case "unix":
-		c.conn, err = net.Dial(uri.Scheme, uri.Path)
+		c.conn, err = net.Dial(c.conf.OpenVpn.Addr.Scheme, c.conf.OpenVpn.Addr.Path)
 	default:
-		return fmt.Errorf("unable to connect to openvpn management interface: unknwon protocol %s", uri.Scheme)
+		return errors.New(utils.StringConcat("unable to connect to openvpn management interface: unknown protocol ", c.conf.OpenVpn.Addr.Scheme))
 	}
 
 	if err != nil {
-		return fmt.Errorf("unable to connect to openvpn management interface %s: %v", uri.String(), err)
+		return errors.New(utils.StringConcat("unable to connect to openvpn management interface ", c.conf.OpenVpn.Addr.String(), ": ", err.Error()))
 	}
 	defer c.conn.Close()
-	c.reader = bufio.NewReader(c.conn)
+	c.scanner = bufio.NewScanner(c.conn)
+	c.scanner.Split(bufio.ScanLines)
+
+	var buf bytes.Buffer
 
 	if c.conf.OpenVpn.Password != "" {
-		if err := c.rawCommand(c.conf.OpenVpn.Password); err != nil {
+		if err := c.rawCommand(utils.StringConcat(c.conf.OpenVpn.Password, "\n")); err != nil {
 			return err
 		}
 
-		line, err := c.readMessage()
-		if err != nil {
+		if err := c.readMessage(&buf); err != nil {
 			return err
 		}
 
-		if !strings.Contains(line, "SUCCESS: password is correct") {
+		if !strings.Contains(buf.String(), "SUCCESS: password is correct") {
 			return errors.New("wrong openvpn management interface password")
 		}
+
+		buf.Reset()
 	}
 
 	go func() {
+		defer close(c.commandResponseCh)
+		defer close(c.clientsCh)
+
+		var buf bytes.Buffer
+
 		for {
-			message, err := c.readMessage()
-			if err != nil {
+			buf.Reset()
+
+			if err := c.readMessage(&buf); err != nil {
 				c.errCh <- err
 				return
 			}
 
-			if strings.HasPrefix(message, ">CLIENT:") {
-				client, err := NewClientConnection(message)
+			if bytes.HasPrefix(buf.Bytes(), []byte(">CLIENT:")) {
+				client, err := NewClientConnection(buf.String())
 				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:") {
-				if strings.HasPrefix(message, "ERROR:") {
-					c.logger.Warn(fmt.Sprintf("Error from OpenVPN: %s", message))
+			} 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 <- message
+				c.commandResponseCh <- buf.String()
 			}
 		}
 	}()
 
 	go func() {
+		var client *ClientConnection
+
 		for {
-			if err := c.processClient(<-c.clientsCh); err != nil {
+			client = <-c.clientsCh
+			if client == nil {
+				return
+			}
+
+			if err := c.processClient(client); err != nil {
 				c.errCh <- err
 				return
 			}
@@ -114,8 +144,14 @@ func (c *Client) Connect() error {
 	}()
 
 	go func() {
+		var command string
 		for {
-			if err := c.rawCommand(<-c.commandsCh); err != nil {
+			command = <-c.commandsCh
+			if command == "" {
+				return
+			}
+
+			if err := c.rawCommand(command); err != nil {
 				c.errCh <- err
 				return
 			}
@@ -129,7 +165,7 @@ func (c *Client) Connect() error {
 	select {
 	case resp := <-respCh:
 		if !strings.HasPrefix(resp, "SUCCESS:") {
-			return fmt.Errorf("invalid openvpn management interface response: %v", resp)
+			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")
@@ -144,10 +180,13 @@ func (c *Client) Connect() error {
 	for {
 		select {
 		case err := <-c.errCh:
-			_ = c.conn.Close()
-			return fmt.Errorf("OpenVPN management error: %v", err)
+			c.close()
+			if err != nil {
+				return errors.New(utils.StringConcat("OpenVPN management error: ", err.Error()))
+			}
+			return nil
 		case <-c.shutdownCh:
-			_ = c.conn.Close()
+			c.close()
 			return nil
 		}
 	}
@@ -176,9 +215,9 @@ func (c *Client) processClient(client *ClientConnection) error {
 			)
 
 			if c.conf.OpenVpn.AuthTokenUser {
-				c.SendCommand("client-auth %d %d\npush \"auth-token-user %s\"\nEND", client.Cid, client.Kid, base64.StdEncoding.EncodeToString([]byte(client.Env["common_name"])))
+				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.SendCommand(`client-auth-nt %d %d`, client.Cid, client.Kid)
+				c.SendCommandf("client-auth-nt %d %d", client.Cid, client.Kid)
 			}
 
 			return nil
@@ -193,16 +232,16 @@ func (c *Client) processClient(client *ClientConnection) error {
 				"username", client.Env["username"],
 			)
 
-			c.SendCommand(`client-deny %d %d "%s" "%s"`, client.Cid, client.Kid, ErrorSsoNotSupported, ErrorSsoNotSupported)
+			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 fmt.Errorf("error encoding state: %s", err)
+			return errors.New(utils.StringConcat("error encoding state: ", err.Error()))
 		}
 
-		startUrl := fmt.Sprintf("%s/oauth2/start?state=%s", strings.TrimSuffix(c.conf.Http.BaseUrl, "/"), url.QueryEscape(session.Encoded))
+		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,
@@ -210,7 +249,7 @@ func (c *Client) processClient(client *ClientConnection) error {
 			"common_name", client.Env["common_name"],
 			"username", client.Env["username"],
 		)
-		c.SendCommand(`client-pending-auth %d %d "WEB_AUTH::%s" %d`, client.Cid, client.Kid, startUrl, 600)
+		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,
@@ -233,43 +272,79 @@ func (c *Client) processClient(client *ClientConnection) error {
 
 // Shutdown shutdowns the client connection
 func (c *Client) Shutdown() {
-	c.logger.Info("shutdown connection")
-	c.shutdownCh <- struct{}{}
+	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(format string, a ...any) string {
-	c.commandsCh <- fmt.Sprintf(format, a...)
+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 {
-	c.logger.Debug(cmd)
-	_, err := fmt.Fprintln(c.conn, cmd)
+	if c.logger.Enabled(context.Background(), slog.LevelDebug) {
+		c.logger.Debug(cmd)
+	}
+	_, err := c.conn.Write([]byte(cmd))
 	return err
 }
 
 // readMessage .
-func (c *Client) readMessage() (string, error) {
-	result := ""
+func (c *Client) readMessage(buf *bytes.Buffer) error {
+	var line []byte
+
 	for {
-		line, err := c.reader.ReadString('\n')
-		if err != nil {
-			return "", err
+		if ok := c.scanner.Scan(); !ok {
+			return c.scanner.Err()
+		}
+
+		line = c.scanner.Bytes()
+
+		if _, err := buf.Write(line); err != nil {
+			return err
 		}
 
-		result += line
-		if strings.HasPrefix(line, ">CLIENT:ENV,END") ||
-			strings.Index(line, "END") == 0 ||
-			strings.Index(line, "SUCCESS:") == 0 ||
-			strings.Index(line, "ERROR:") == 0 ||
-			strings.HasPrefix(line, ">HOLD:") ||
-			strings.HasPrefix(line, ">INFO:") ||
-			strings.HasPrefix(line, "ENTER PASSWORD:") {
-
-			c.logger.Debug(result)
-			return result, nil
+		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) 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/client_connection.go b/internal/openvpn/client_connection.go
index 4f876bf..ccdbff1 100644
--- a/internal/openvpn/client_connection.go
+++ b/internal/openvpn/client_connection.go
@@ -2,9 +2,10 @@ package openvpn
 
 import (
 	"errors"
-	"fmt"
 	"strconv"
 	"strings"
+
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
 )
 
 type ClientConnection struct {
@@ -19,7 +20,7 @@ func NewClientConnection(message string) (*ClientConnection, error) {
 		Env: map[string]string{},
 	}
 
-	for _, line := range strings.Split(strings.TrimSpace(message), "\r\n") {
+	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") ||
@@ -27,7 +28,7 @@ func NewClientConnection(message string) (*ClientConnection, error) {
 			strings.HasPrefix(line, ">CLIENT:CR_RESPONSE") {
 			clientInfo := strings.Split(strings.TrimSpace(line), ",")
 			if len(clientInfo) == 1 {
-				return nil, fmt.Errorf("unable to parse line %s", line)
+				return nil, errors.New(utils.StringConcat("unable to parse line ", line))
 			}
 
 			clientConnection.Reason = strings.Replace(clientInfo[0], ">CLIENT:", "", 1)
diff --git a/internal/state/state.go b/internal/state/state.go
index 67da756..2552782 100644
--- a/internal/state/state.go
+++ b/internal/state/state.go
@@ -2,9 +2,10 @@ package state
 
 import (
 	"encoding/json"
-	"fmt"
+	"errors"
 	"time"
 
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/utils"
 	"github.com/zitadel/oidc/v2/pkg/crypto"
 )
 
@@ -37,7 +38,7 @@ func NewEncoded(state string) *State {
 func (state *State) Decode(secretKey string) error {
 	jsonState, err := crypto.DecryptAES(state.Encoded, secretKey)
 	if err != nil {
-		return fmt.Errorf("invalid state: %v: %v", state.Encoded, err)
+		return errors.New(utils.StringConcat("invalid state: ", state.Encoded, ": ", err.Error()))
 	}
 
 	if err := json.Unmarshal([]byte(jsonState), &state); err != nil {
@@ -47,9 +48,9 @@ func (state *State) Decode(secretKey string) error {
 	issuedSince := time.Since(state.Issued)
 
 	if issuedSince >= time.Minute*2 {
-		return fmt.Errorf("state expired after 2 minutes, issued at: %s", state.Issued.String())
+		return errors.New(utils.StringConcat("state expired after 2 minutes, issued at: ", state.Issued.String()))
 	} else if issuedSince <= time.Second*-5 {
-		return fmt.Errorf("state issued in future, issued at: %s", state.Issued.String())
+		return errors.New(utils.StringConcat("state issued in future, issued at: ", state.Issued.String()))
 	}
 
 	return nil
diff --git a/internal/utils/strings.go b/internal/utils/strings.go
new file mode 100644
index 0000000..fc6c322
--- /dev/null
+++ b/internal/utils/strings.go
@@ -0,0 +1,11 @@
+package utils
+
+import "strings"
+
+func StringConcat(strs ...string) string {
+	var sb strings.Builder
+	for _, str := range strs {
+		sb.WriteString(str)
+	}
+	return sb.String()
+}
diff --git a/internal/utils/url.go b/internal/utils/url.go
new file mode 100644
index 0000000..0ccace4
--- /dev/null
+++ b/internal/utils/url.go
@@ -0,0 +1,7 @@
+package utils
+
+import "net/url"
+
+func IsUrlEmpty(url *url.URL) bool {
+	return url == nil || url.String() == ""
+}


Test patch:
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 61f433f..5af7a10 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -1,6 +1,7 @@
 package config
 
 import (
+	"net/url"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -65,28 +66,26 @@ func TestValidate(t *testing.T) {
 		{"", &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: "invalid"}, Oauth2: &OAuth2{Client: &OAuth2Client{}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "-"},
-		{"", &Config{Http: &Http{BaseUrl: "invalid", Secret: "invalid"}, Oauth2: &OAuth2{Issuer: "invalid", Client: &OAuth2Client{}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "-"},
-		{"", &Config{Http: &Http{BaseUrl: "invalid", Secret: "invalid"}, Oauth2: &OAuth2{Issuer: "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: "invalid", Secret: "0123456789101112"}, Oauth2: &OAuth2{Issuer: "invalid", Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "openvpn.addr: invalid URL. only tcp://addr or unix://addr scheme supported"},
-		{"", &Config{Http: &Http{BaseUrl: "http://invalid", Secret: "0123456789101112"}, Oauth2: &OAuth2{Issuer: "invalid", Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Addr: "://-", Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "openvpn.addr: invalid URL. error: parse \"://-\": missing protocol scheme"},
-		{"", &Config{Http: &Http{BaseUrl: "http://invalid", Secret: "0123456789101112"}, Oauth2: &OAuth2{Issuer: "invalid", Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Addr: "tcp://127.0.0.1:9000", Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "-"},
-		{"", &Config{Http: &Http{BaseUrl: "://-", Secret: "0123456789101112"}, Oauth2: &OAuth2{Issuer: "http://invalid", Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Addr: "tcp://127.0.0.1:9000", Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "-"},
-		{"", &Config{Http: &Http{BaseUrl: "http://invalid", Secret: "0123456789101112"}, Oauth2: &OAuth2{Issuer: "http://invalid", Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Addr: "tcp://127.0.0.1:9000", Bypass: &OpenVpnBypass{}}, Log: &Log{}}, ""},
-		{"", &Config{Http: &Http{BaseUrl: "http://invalid", Secret: "0123456789101112"}, Oauth2: &OAuth2{Issuer: "http://invalid", Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{Token: "://-"}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Addr: "tcp://127.0.0.1:9000", Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "oauth2.endpoint.token: invalid URL. error: parse \"://-\": missing protocol scheme"},
-		{"", &Config{Http: &Http{BaseUrl: "http://invalid", Secret: "0123456789101112"}, Oauth2: &OAuth2{Issuer: "http://invalid", Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{Token: "http://invalid"}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Addr: "tcp://127.0.0.1:9000", Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "both oauth2.endpoints.tokenUrl and oauth2.endpoints.authUrl are required"},
-		{"", &Config{Http: &Http{BaseUrl: "http://invalid", Secret: "0123456789101112"}, Oauth2: &OAuth2{Issuer: "http://invalid", Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{Token: "http://invalid", Auth: "http://invalid"}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Addr: "tcp://127.0.0.1:9000", Bypass: &OpenVpnBypass{}}, Log: &Log{}}, ""},
-		{"", &Config{Http: &Http{BaseUrl: "http://invalid", Secret: "0123456789101112", CallbackTemplatePath: "nonexists"}, Oauth2: &OAuth2{Issuer: "http://invalid", Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{Token: "http://invalid", Auth: "http://invalid"}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Addr: "tcp://127.0.0.1:9000", Bypass: &OpenVpnBypass{}}, Log: &Log{}}, "http.callbackTemplatePath: invalid template: open nonexists: no such file or directory"},
-		{"", &Config{Http: &Http{BaseUrl: "http://invalid", Secret: "0123456789101112", CallbackTemplatePath: "/dev/null"}, Oauth2: &OAuth2{Issuer: "http://invalid", Client: &OAuth2Client{Id: "client"}, Endpoints: &OAuth2Endpoints{Token: "http://invalid", Auth: "http://invalid"}, Validate: &OAuth2Validate{}}, OpenVpn: &OpenVpn{Addr: "tcp://127.0.0.1:9000", 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{}}, ""},
 	} {
 		t.Run(tt.name, func(t *testing.T) {
 			err := Validate(tt.config)
 			if tt.err == "" {
 				assert.NoError(t, err)
 			} else {
-				assert.Error(t, err)
-
-				if tt.err != "-" {
+				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 9605f12..391ab19 100644
--- a/internal/oauth2/handler_test.go
+++ b/internal/oauth2/handler_test.go
@@ -10,9 +10,11 @@ import (
 	"net/http"
 	"net/http/cookiejar"
 	"net/http/httptest"
+	"net/url"
 	"strings"
 	"sync"
 	"testing"
+	"time"
 
 	"github.com/gorilla/mux"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
@@ -27,118 +29,293 @@ import (
 func TestHandler(t *testing.T) {
 	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
 
-	managementInterface, err := net.Listen("tcp", "127.0.0.1:0")
-	assert.NoError(t, err)
-	defer managementInterface.Close()
-
-	clientListener, err := net.Listen("tcp", "127.0.0.1:0")
-	assert.NoError(t, err)
-	defer clientListener.Close()
-
-	resourceServer, err := setupResourceServer(clientListener)
-	assert.NoError(t, err)
-	defer resourceServer.Close()
-
-	conf := &config.Config{
-		Http: &config.Http{
-			BaseUrl: fmt.Sprintf("http://%s/", clientListener.Addr().String()),
-			Secret:  "0123456789101112",
+	confs := []struct {
+		name          string
+		conf          *config.Config
+		ipaddr        string
+		xForwardedFor string
+		allow         bool
+	}{
+		{
+			"default",
+			&config.Config{
+				Http: &config.Http{
+					Secret: "0123456789101112",
+					Check: &config.HttpCheck{
+						IpAddr: false,
+					},
+				},
+				Oauth2: &config.OAuth2{
+					Provider:  "generic",
+					Client:    &config.OAuth2Client{Id: "ID", Secret: "SECRET"},
+					Endpoints: &config.OAuth2Endpoints{},
+					Scopes:    []string{"openid", "profile"},
+					Validate: &config.OAuth2Validate{
+						Groups: make([]string, 0),
+						Roles:  make([]string, 0),
+						Issuer: true,
+						IpAddr: false,
+					},
+				},
+				OpenVpn: &config.OpenVpn{
+					Bypass:        &config.OpenVpnBypass{CommonNames: []string{}},
+					AuthTokenUser: true,
+				},
+			},
+			"127.0.0.1",
+			"",
+			true,
+		},
+		{
+			"with ipaddr",
+			&config.Config{
+				Http: &config.Http{
+					Secret: "0123456789101112",
+					Check: &config.HttpCheck{
+						IpAddr: true,
+					},
+				},
+				Oauth2: &config.OAuth2{
+					Provider:  "generic",
+					Client:    &config.OAuth2Client{Id: "ID", Secret: "SECRET"},
+					Endpoints: &config.OAuth2Endpoints{},
+					Scopes:    []string{"openid", "profile"},
+					Validate: &config.OAuth2Validate{
+						Groups: make([]string, 0),
+						Roles:  make([]string, 0),
+						Issuer: true,
+						IpAddr: false,
+					},
+				},
+				OpenVpn: &config.OpenVpn{
+					Bypass:        &config.OpenVpnBypass{CommonNames: []string{}},
+					AuthTokenUser: true,
+				},
+			},
+			"127.0.0.1",
+			"",
+			true,
+		},
+		{
+			"with ipaddr + forwarded-for",
+			&config.Config{
+				Http: &config.Http{
+					Secret: "0123456789101112",
+					Check: &config.HttpCheck{
+						IpAddr: true,
+					},
+					EnableProxyHeaders: true,
+				},
+				Oauth2: &config.OAuth2{
+					Provider:  "generic",
+					Client:    &config.OAuth2Client{Id: "ID", Secret: "SECRET"},
+					Endpoints: &config.OAuth2Endpoints{},
+					Scopes:    []string{"openid", "profile"},
+					Validate: &config.OAuth2Validate{
+						Groups: make([]string, 0),
+						Roles:  make([]string, 0),
+						Issuer: true,
+						IpAddr: false,
+					},
+				},
+				OpenVpn: &config.OpenVpn{
+					Bypass:        &config.OpenVpnBypass{CommonNames: []string{}},
+					AuthTokenUser: true,
+				},
+			},
+			"127.0.0.2",
+			"127.0.0.2",
+			true,
 		},
-		Oauth2: &config.OAuth2{
-			Issuer:    resourceServer.URL,
-			Provider:  "oidc",
-			Client:    &config.OAuth2Client{Id: "ID", Secret: "SECRET"},
-			Endpoints: &config.OAuth2Endpoints{},
-			Scopes:    []string{"openid", "profile"},
-			Validate: &config.OAuth2Validate{
-				Groups: make([]string, 0),
-				Roles:  make([]string, 0),
-				Issuer: true,
-				IpAddr: false,
+		{
+			"with ipaddr + disabled forwarded-for",
+			&config.Config{
+				Http: &config.Http{
+					Secret: "0123456789101112",
+					Check: &config.HttpCheck{
+						IpAddr: true,
+					},
+					EnableProxyHeaders: false,
+				},
+				Oauth2: &config.OAuth2{
+					Provider:  "generic",
+					Client:    &config.OAuth2Client{Id: "ID", Secret: "SECRET"},
+					Endpoints: &config.OAuth2Endpoints{},
+					Scopes:    []string{"openid", "profile"},
+					Validate: &config.OAuth2Validate{
+						Groups: make([]string, 0),
+						Roles:  make([]string, 0),
+						Issuer: true,
+						IpAddr: false,
+					},
+				},
+				OpenVpn: &config.OpenVpn{
+					Bypass:        &config.OpenVpnBypass{CommonNames: []string{}},
+					AuthTokenUser: true,
+				},
 			},
+			"127.0.0.2",
+			"127.0.0.2",
+			false,
 		},
-		OpenVpn: &config.OpenVpn{
-			Addr:          fmt.Sprintf("%s://%s", managementInterface.Addr().Network(), managementInterface.Addr().String()),
-			Bypass:        &config.OpenVpnBypass{CommonNames: []string{}},
-			AuthTokenUser: true,
+		{
+			"with ipaddr + multiple forwarded-for",
+			&config.Config{
+				Http: &config.Http{
+					Secret: "0123456789101112",
+					Check: &config.HttpCheck{
+						IpAddr: true,
+					},
+					EnableProxyHeaders: true,
+				},
+				Oauth2: &config.OAuth2{
+					Provider:  "generic",
+					Client:    &config.OAuth2Client{Id: "ID", Secret: "SECRET"},
+					Endpoints: &config.OAuth2Endpoints{},
+					Scopes:    []string{"openid", "profile"},
+					Validate: &config.OAuth2Validate{
+						Groups: make([]string, 0),
+						Roles:  make([]string, 0),
+						Issuer: true,
+						IpAddr: false,
+					},
+				},
+				OpenVpn: &config.OpenVpn{
+					Bypass:        &config.OpenVpnBypass{CommonNames: []string{}},
+					AuthTokenUser: true,
+				},
+			},
+			"127.0.0.2",
+			"127.0.0.2, 8.8.8.8",
+			true,
 		},
 	}
 
-	client := openvpn.NewClient(logger, conf)
-	defer client.Shutdown()
-
-	wg := sync.WaitGroup{}
-	wg.Add(2)
-	go func() {
-		defer wg.Done()
-		conn, err := managementInterface.Accept()
-		assert.NoError(t, err)
-
-		defer conn.Close() //nolint:errcheck
-		reader := bufio.NewReader(conn)
-
-		sendLine(t, conn, ">INFO:OpenVPN Management Interface Version 5 -- type 'help' for more info\r\n")
-		assert.Equal(t, "hold release", readLine(t, reader))
-		sendLine(t, conn, "SUCCESS: hold release succeeded\r\n")
-		assert.Equal(t, "version", readLine(t, reader))
-
-		sendLine(t, conn, "OpenVPN Version: OpenVPN Mock\r\nEND\r\n")
-		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))
-		sendLine(t, conn, "SUCCESS: client-auth-nt command succeeded\r\n")
-	}()
-
-	go func() {
-		defer wg.Done()
-		err := client.Connect()
-		if err != nil && !strings.HasSuffix(err.Error(), "EOF") {
+	for _, tt := range confs {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			managementInterface, err := net.Listen("tcp", "127.0.0.1:0")
 			assert.NoError(t, err)
-		}
-	}()
+			defer managementInterface.Close()
 
-	provider, err := NewProvider(logger, conf)
-	if !assert.NoError(t, err) {
-		return
-	}
+			clientListener, err := net.Listen("tcp", "127.0.0.1:0")
+			assert.NoError(t, err)
+			defer clientListener.Close()
 
-	httpClientListener := httptest.NewUnstartedServer(Handler(logger, provider, conf, client))
-	httpClientListener.Listener.Close()
-	httpClientListener.Listener = clientListener
-	httpClientListener.Start()
-	defer httpClientListener.Close()
+			resourceServer, err := setupResourceServer(clientListener)
+			assert.NoError(t, err)
+			defer resourceServer.Close()
 
-	httpClient := httpClientListener.Client()
+			resourceServerUrl, err := url.Parse(resourceServer.URL)
+			assert.NoError(t, err)
 
-	jar, err := cookiejar.New(nil)
-	if !assert.NoError(t, err) {
-		return
-	}
+			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()}
 
-	httpClient.Jar = jar
+			client := openvpn.NewClient(logger, tt.conf)
+			defer client.Shutdown()
 
-	sessionState := state.New(0, 1, "127.0.0.1", "name")
-	err = sessionState.Encode(conf.Http.Secret)
-	if !assert.NoError(t, err) {
-		return
-	}
+			provider, err := NewProvider(logger, tt.conf)
+			if !assert.NoError(t, err) {
+				return
+			}
 
-	resp, err := httpClient.Get(fmt.Sprintf("%s/oauth2/start?state=%s", httpClientListener.URL, sessionState.Encoded))
-	if !assert.NoError(t, err) {
-		return
-	}
+			httpClientListener := httptest.NewUnstartedServer(Handler(logger, provider, tt.conf, client))
+			httpClientListener.Listener.Close()
+			httpClientListener.Listener = clientListener
+			httpClientListener.Start()
+			defer httpClientListener.Close()
 
-	body, err := io.ReadAll(resp.Body)
-	if !assert.NoError(t, err) {
-		return
-	}
-	_ = resp.Body.Close()
+			httpClient := httpClientListener.Client()
 
-	if !assert.Equal(t, 200, resp.StatusCode, string(body)) {
-		return
-	}
+			wg := sync.WaitGroup{}
+			wg.Add(2)
+			go func() {
+				defer wg.Done()
+				defer client.Shutdown()
+				defer httpClientListener.Close()
+
+				conn, err := managementInterface.Accept()
+				assert.NoError(t, err)
+
+				defer conn.Close() //nolint:errcheck
+				reader := bufio.NewReader(conn)
+
+				sendLine(t, conn, ">INFO:OpenVPN Management Interface Version 5 -- type 'help' for more info\r\n")
+				assert.Equal(t, "hold release", readLine(t, reader))
+				sendLine(t, conn, "SUCCESS: hold release succeeded\r\n")
+				assert.Equal(t, "version", readLine(t, reader))
+
+				sendLine(t, conn, "OpenVPN Version: OpenVPN Mock\r\nEND\r\n")
+
+				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))
+				}
+
+				sendLine(t, conn, "SUCCESS: client-auth command succeeded\r\n")
+			}()
+
+			go func() {
+				defer wg.Done()
+				err := client.Connect()
+				if err != nil && !strings.HasSuffix(err.Error(), "EOF") {
+					assert.NoError(t, err)
+				}
+			}()
+
+			jar, err := cookiejar.New(nil)
+			if !assert.NoError(t, err) {
+				return
+			}
+
+			httpClient.Jar = jar
 
-	wg.Wait()
+			sessionState := state.New(0, 1, tt.ipaddr, "name")
+			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)
+			if !assert.NoError(t, err) {
+				return
+			}
+
+			if tt.xForwardedFor != "" {
+				request.Header.Set("X-Forwarded-For", tt.xForwardedFor)
+			}
+
+			resp, err := httpClient.Do(request)
+			if !assert.NoError(t, err) {
+				return
+			}
+
+			body, err := io.ReadAll(resp.Body)
+			if !assert.NoError(t, err) {
+				return
+			}
+			_ = resp.Body.Close()
+
+			expectedStatus := 200
+			if !tt.allow {
+				expectedStatus = 403
+			}
+
+			if !assert.Equal(t, expectedStatus, resp.StatusCode, string(body)) {
+				return
+			}
+
+			client.Shutdown()
+			wg.Wait()
+		})
+	}
 }
 
 func setupResourceServer(clientListener net.Listener) (*httptest.Server, error) {
diff --git a/internal/oauth2/provider_test.go b/internal/oauth2/provider_test.go
index 01de3da..f196f9a 100644
--- a/internal/oauth2/provider_test.go
+++ b/internal/oauth2/provider_test.go
@@ -5,9 +5,11 @@ import (
 	"io"
 	"log/slog"
 	"net/http/httptest"
+	"net/url"
 	"strings"
 	"testing"
 
+	"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"
@@ -37,6 +39,8 @@ func TestNewProvider(t *testing.T) {
 	svr := httptest.NewServer(handler.HttpHandler())
 	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
 
+	svrUrl, _ := url.Parse(svr.URL)
+
 	configs := []struct {
 		name   string
 		config *config.Config
@@ -45,10 +49,10 @@ func TestNewProvider(t *testing.T) {
 		{
 			"default",
 			&config.Config{
-				Http: &config.Http{BaseUrl: "http://localhost/"},
+				Http: &config.Http{BaseUrl: &url.URL{Scheme: "http", Host: "localhost"}},
 				Oauth2: &config.OAuth2{
-					Issuer:    svr.URL,
-					Provider:  "oidc",
+					Issuer:    svrUrl,
+					Provider:  generic.Name,
 					Client:    &config.OAuth2Client{Id: "ID", Secret: "ID"},
 					Endpoints: &config.OAuth2Endpoints{},
 				},
@@ -58,13 +62,13 @@ func TestNewProvider(t *testing.T) {
 		{
 			"with custom discovery",
 			&config.Config{
-				Http: &config.Http{BaseUrl: "http://localhost/"},
+				Http: &config.Http{BaseUrl: &url.URL{Scheme: "http", Host: "localhost"}},
 				Oauth2: &config.OAuth2{
-					Issuer:   svr.URL,
-					Provider: "oidc",
+					Issuer:   svrUrl,
+					Provider: generic.Name,
 					Client:   &config.OAuth2Client{Id: "ID", Secret: "ID"},
 					Endpoints: &config.OAuth2Endpoints{
-						Discovery: svr.URL + "/.well-known/openid-configuration",
+						Discovery: &url.URL{Scheme: svrUrl.Scheme, Host: svrUrl.Host, Path: "/.well-known/openid-configuration"},
 					},
 				},
 			},
@@ -73,13 +77,13 @@ func TestNewProvider(t *testing.T) {
 		{
 			"with invalid custom discovery",
 			&config.Config{
-				Http: &config.Http{BaseUrl: "http://localhost/"},
+				Http: &config.Http{BaseUrl: &url.URL{Scheme: "http", Host: "localhost"}},
 				Oauth2: &config.OAuth2{
-					Issuer:   svr.URL,
-					Provider: "oidc",
+					Issuer:   svrUrl,
+					Provider: generic.Name,
 					Client:   &config.OAuth2Client{Id: "ID", Secret: "ID"},
 					Endpoints: &config.OAuth2Endpoints{
-						Discovery: svr.URL + "/.well-known/openid-config",
+						Discovery: &url.URL{Scheme: svrUrl.Scheme, Host: svrUrl.Host, Path: "/.well-known/openid-config"},
 					},
 				},
 			},
@@ -88,50 +92,49 @@ func TestNewProvider(t *testing.T) {
 		{
 			"with custom endpoints",
 			&config.Config{
-				Http: &config.Http{BaseUrl: "http://localhost/"},
+				Http: &config.Http{BaseUrl: &url.URL{Scheme: "http", Host: "localhost"}},
 				Oauth2: &config.OAuth2{
-					Issuer:   svr.URL,
-					Provider: "oidc",
+					Issuer:   svrUrl,
+					Provider: generic.Name,
 					Client:   &config.OAuth2Client{Id: "ID", Secret: "ID"},
 					Endpoints: &config.OAuth2Endpoints{
-						Discovery: svr.URL + "/.well-known/openid-config",
-						Auth:      svr.URL + "/.well-known/authorize",
-						Token:     svr.URL + "/.well-known/token",
+						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"},
 					},
 				},
 			},
 			"",
 		},
 		{
-			"with invalid base url",
+			"with missing custom endpoints",
 			&config.Config{
-				Http: &config.Http{BaseUrl: "http://-"},
+				Http: &config.Http{BaseUrl: &url.URL{Scheme: "http", Host: "localhost"}},
 				Oauth2: &config.OAuth2{
-					Issuer:   svr.URL,
-					Provider: "oidc",
+					Issuer:   svrUrl,
+					Provider: generic.Name,
 					Client:   &config.OAuth2Client{Id: "ID", Secret: "ID"},
 					Endpoints: &config.OAuth2Endpoints{
-						Discovery: svr.URL + "/.well-known/openid-config",
-						Auth:      svr.URL + "/.well-known/authorize",
-						Token:     svr.URL + "/.well-known/token",
+						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",
 		},
 		{
 			"with pkce",
 			&config.Config{
-				Http: &config.Http{BaseUrl: "http://localhost/"},
+				Http: &config.Http{BaseUrl: &url.URL{Scheme: "http", Host: "localhost"}},
 				Oauth2: &config.OAuth2{
-					Issuer:   svr.URL,
-					Provider: "oidc",
+					Issuer:   svrUrl,
+					Provider: generic.Name,
 					Pkce:     true,
 					Client:   &config.OAuth2Client{Id: "ID", Secret: "ID"},
 					Endpoints: &config.OAuth2Endpoints{
-						Discovery: svr.URL + "/.well-known/openid-config",
-						Auth:      svr.URL + "/.well-known/authorize",
-						Token:     svr.URL + "/.well-known/token",
+						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"},
 					},
 				},
 			},
@@ -141,7 +144,7 @@ func TestNewProvider(t *testing.T) {
 	for _, tt := range configs {
 		t.Run(tt.name, func(t *testing.T) {
 			provider, err := NewProvider(logger, tt.config)
-			if tt.err != "" {
+			if tt.err != "" && assert.Error(t, err) {
 				assert.Equal(t, strings.TrimSpace(err.Error()), tt.err)
 				return
 			}
@@ -150,13 +153,13 @@ func TestNewProvider(t *testing.T) {
 
 			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 != "" {
-				assert.Equal(t, provider.OAuthConfig().Endpoint.AuthURL, tt.config.Oauth2.Endpoints.Auth)
+			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 != "" {
-				assert.Equal(t, provider.OAuthConfig().Endpoint.TokenURL, tt.config.Oauth2.Endpoints.Token)
+			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/oidc/check_test.go b/internal/oauth2/providers/generic/check_test.go
similarity index 99%
rename from internal/oauth2/providers/oidc/check_test.go
rename to internal/oauth2/providers/generic/check_test.go
index b93e0e3..aebaceb 100644
--- a/internal/oauth2/providers/oidc/check_test.go
+++ b/internal/oauth2/providers/generic/check_test.go
@@ -1,4 +1,4 @@
-package oidc
+package generic
 
 import (
 	"context"
diff --git a/internal/openvpn/client_connection_test.go b/internal/openvpn/client_connection_test.go
index 93a5757..d67fc97 100644
--- a/internal/openvpn/client_connection_test.go
+++ b/internal/openvpn/client_connection_test.go
@@ -25,7 +25,7 @@ func TestNewClientConnection(t *testing.T) {
 		{"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, "\r\n")
+			message := strings.Join(tt.lines, "\n")
 
 			clientConnection, err := NewClientConnection(message)
 			if tt.err == "" {
diff --git a/internal/openvpn/client_test.go b/internal/openvpn/client_test.go
index cc55d72..887245d 100644
--- a/internal/openvpn/client_test.go
+++ b/internal/openvpn/client_test.go
@@ -7,6 +7,7 @@ import (
 	"io"
 	"log/slog"
 	"net"
+	"net/url"
 	"regexp"
 	"strings"
 	"sync"
@@ -21,11 +22,11 @@ func TestClientInvalidServer(t *testing.T) {
 	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
 	conf := &config.Config{
 		Http: &config.Http{
-			BaseUrl: "http://localhost/",
+			BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
 			Secret:  "0123456789101112",
 		},
 		OpenVpn: &config.OpenVpn{
-			Addr:   "tcp://0.0.0.0:1",
+			Addr:   &url.URL{Scheme: "tcp", Host: "0.0.0.0:1"},
 			Bypass: &config.OpenVpnBypass{CommonNames: make([]string, 0)},
 		},
 	}
@@ -52,11 +53,11 @@ func TestClientFull(t *testing.T) {
 			"without password",
 			&config.Config{
 				Http: &config.Http{
-					BaseUrl: "http://localhost/",
+					BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
 					Secret:  "0123456789101112",
 				},
 				OpenVpn: &config.OpenVpn{
-					Addr:   fmt.Sprintf("%s://%s", l.Addr().Network(), l.Addr().String()),
+					Addr:   &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
 					Bypass: &config.OpenVpnBypass{CommonNames: make([]string, 0)},
 				},
 			},
@@ -68,11 +69,11 @@ func TestClientFull(t *testing.T) {
 			"with password",
 			&config.Config{
 				Http: &config.Http{
-					BaseUrl: "http://localhost/",
+					BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
 					Secret:  "0123456789101112",
 				},
 				OpenVpn: &config.OpenVpn{
-					Addr:     fmt.Sprintf("%s://%s", l.Addr().Network(), l.Addr().String()),
+					Addr:     &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
 					Bypass:   &config.OpenVpnBypass{CommonNames: make([]string, 0)},
 					Password: "password",
 				},
@@ -85,11 +86,11 @@ func TestClientFull(t *testing.T) {
 			"with invalid state",
 			&config.Config{
 				Http: &config.Http{
-					BaseUrl: "http://localhost/",
+					BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
 					Secret:  "012345678910111",
 				},
 				OpenVpn: &config.OpenVpn{
-					Addr:     fmt.Sprintf("%s://%s", l.Addr().Network(), l.Addr().String()),
+					Addr:     &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
 					Bypass:   &config.OpenVpnBypass{CommonNames: make([]string, 0)},
 					Password: "password",
 				},
@@ -102,11 +103,11 @@ func TestClientFull(t *testing.T) {
 			"client without IV_SSO",
 			&config.Config{
 				Http: &config.Http{
-					BaseUrl: "http://localhost/",
+					BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
 					Secret:  "0123456789101112",
 				},
 				OpenVpn: &config.OpenVpn{
-					Addr:     fmt.Sprintf("%s://%s", l.Addr().Network(), l.Addr().String()),
+					Addr:     &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
 					Bypass:   &config.OpenVpnBypass{CommonNames: make([]string, 0)},
 					Password: "password",
 				},
@@ -119,11 +120,11 @@ func TestClientFull(t *testing.T) {
 			"client bypass",
 			&config.Config{
 				Http: &config.Http{
-					BaseUrl: "http://localhost/",
+					BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
 					Secret:  "0123456789101112",
 				},
 				OpenVpn: &config.OpenVpn{
-					Addr:     fmt.Sprintf("%s://%s", l.Addr().Network(), l.Addr().String()),
+					Addr:     &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
 					Bypass:   &config.OpenVpnBypass{CommonNames: []string{"bypass"}},
 					Password: "password",
 				},
@@ -136,11 +137,11 @@ func TestClientFull(t *testing.T) {
 			"client established",
 			&config.Config{
 				Http: &config.Http{
-					BaseUrl: "http://localhost/",
+					BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
 					Secret:  "0123456789101112",
 				},
 				OpenVpn: &config.OpenVpn{
-					Addr:     fmt.Sprintf("%s://%s", l.Addr().Network(), l.Addr().String()),
+					Addr:     &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
 					Bypass:   &config.OpenVpnBypass{CommonNames: []string{"bypass"}},
 					Password: "password",
 				},
@@ -153,11 +154,11 @@ func TestClientFull(t *testing.T) {
 			"client disconnected",
 			&config.Config{
 				Http: &config.Http{
-					BaseUrl: "http://localhost/",
+					BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
 					Secret:  "0123456789101112",
 				},
 				OpenVpn: &config.OpenVpn{
-					Addr:     fmt.Sprintf("%s://%s", l.Addr().Network(), l.Addr().String()),
+					Addr:     &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
 					Bypass:   &config.OpenVpnBypass{CommonNames: []string{"bypass"}},
 					Password: "password",
 				},
@@ -170,11 +171,11 @@ func TestClientFull(t *testing.T) {
 			"client invalid reason",
 			&config.Config{
 				Http: &config.Http{
-					BaseUrl: "http://localhost/",
+					BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
 					Secret:  "0123456789101112",
 				},
 				OpenVpn: &config.OpenVpn{
-					Addr:     fmt.Sprintf("%s://%s", l.Addr().Network(), l.Addr().String()),
+					Addr:     &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
 					Bypass:   &config.OpenVpnBypass{CommonNames: []string{"bypass"}},
 					Password: "password",
 				},
@@ -198,6 +199,8 @@ func TestClientFull(t *testing.T) {
 				assert.NoError(t, err)
 
 				defer conn.Close() //nolint:errcheck
+				defer client.Shutdown()
+
 				reader := bufio.NewReader(conn)
 
 				if tt.conf.OpenVpn.Password != "" {
@@ -217,7 +220,6 @@ func TestClientFull(t *testing.T) {
 					_, _ = reader.ReadString('\n')
 					return
 				} else if tt.expect == "" {
-					client.Shutdown() //nolint:errcheck
 					return
 				}
 
@@ -240,7 +242,6 @@ func TestClientFull(t *testing.T) {
 					assert.Equal(t, "test", sessionState.CommonName)
 					assert.Equal(t, "127.0.0.1", sessionState.Ipaddr)
 				}
-				client.Shutdown() //nolint:errcheck
 			}()
 
 			err := client.Connect()
@@ -267,11 +268,11 @@ func TestClientInvalidPassword(t *testing.T) {
 
 	conf := &config.Config{
 		Http: &config.Http{
-			BaseUrl: "http://localhost/",
+			BaseUrl: &url.URL{Scheme: "http", Host: "localhost"},
 			Secret:  "0123456789101112",
 		},
 		OpenVpn: &config.OpenVpn{
-			Addr:     fmt.Sprintf("%s://%s", l.Addr().Network(), l.Addr().String()),
+			Addr:     &url.URL{Scheme: l.Addr().Network(), Host: l.Addr().String()},
 			Bypass:   &config.OpenVpnBypass{CommonNames: make([]string, 0)},
 			Password: "invalid",
 		},
@@ -300,6 +301,72 @@ func TestClientInvalidPassword(t *testing.T) {
 	client.Shutdown()
 }
 
+func TestClientFullStress(t *testing.T) {
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+	l, err := net.Listen("tcp", "127.0.0.1:0")
+	assert.NoError(t, err)
+	defer l.Close()
+
+	conf := &config.Config{
+		Http: &config.Http{
+			BaseUrl: &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)},
+		},
+	}
+
+	clientMessage := ">CLIENT:CONNECT,%d,%d\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"
+	expectMessage := "client-pending-auth %d %d \"WEB_AUTH::"
+
+	client := NewClient(logger, conf)
+	wg := sync.WaitGroup{}
+	wg.Add(1)
+
+	go func() {
+		defer wg.Done()
+		conn, err := l.Accept()
+		assert.NoError(t, err)
+
+		defer conn.Close() //nolint:errcheck
+		defer client.Shutdown()
+
+		reader := bufio.NewReader(conn)
+		sendLine(t, conn, ">INFO:OpenVPN Management Interface Version 5 -- type 'help' for more info\r\n")
+		assert.Equal(t, "hold release", readLine(t, reader))
+		sendLine(t, conn, "SUCCESS: hold release succeeded\r\n")
+		assert.Equal(t, "version", readLine(t, reader))
+
+		sendLine(t, conn, "OpenVPN Version: OpenVPN Mock\r\nEND\r\n")
+
+		for clientId := 1; clientId <= 100; clientId++ {
+			sendLine(t, conn, fmt.Sprintf(clientMessage, clientId, clientId))
+			auth := readLine(t, reader)
+			assert.Contains(t, auth, fmt.Sprintf(expectMessage, clientId, clientId))
+			sendLine(t, conn, "SUCCESS: %s command succeeded\r\n", strings.SplitN(auth, " ", 2)[0])
+			matches := regexp.MustCompile(`state=(.+)"`).FindStringSubmatch(auth)
+			assert.Len(t, matches, 2)
+
+			sessionState := state.NewEncoded(matches[1])
+			err := sessionState.Decode(conf.Http.Secret)
+			assert.NoError(t, err)
+
+			assert.Equal(t, uint64(clientId), sessionState.Cid)
+			assert.Equal(t, uint64(clientId), sessionState.Kid)
+			assert.Equal(t, "test", sessionState.CommonName)
+			assert.Equal(t, "127.0.0.1", sessionState.Ipaddr)
+		}
+	}()
+
+	err = client.Connect()
+	wg.Wait()
+	if err != nil && !strings.HasSuffix(err.Error(), "EOF") {
+		assert.NoError(t, err)
+	}
+}
+
 func sendLine(t *testing.T, conn net.Conn, msg string, a ...any) {
 	_, err := fmt.Fprintf(conn, msg, a...)
 	assert.NoError(t, err)
diff --git a/internal/utils/strings_test.go b/internal/utils/strings_test.go
new file mode 100644
index 0000000..ee1ebc0
--- /dev/null
+++ b/internal/utils/strings_test.go
@@ -0,0 +1,11 @@
+package utils
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestStringConcat(t *testing.T) {
+	assert.Equal(t, "aabbcc", StringConcat("aa", "b", "b", "cc"))
+}
diff --git a/internal/utils/url_test.go b/internal/utils/url_test.go
new file mode 100644
index 0000000..7109192
--- /dev/null
+++ b/internal/utils/url_test.go
@@ -0,0 +1,36 @@
+package utils
+
+import (
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestIsUrlEmpty(t *testing.T) {
+	for _, tt := range []struct {
+		name   string
+		url    *url.URL
+		expect bool
+	}{
+		{
+			"nil",
+			nil,
+			true,
+		},
+		{
+			"empty",
+			&url.URL{},
+			true,
+		},
+		{
+			"non-empty",
+			&url.URL{Scheme: "http", Host: "localhost"},
+			false,
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.expect, IsUrlEmpty(tt.url))
+		})
+	}
+}